Spec Kit GitHub Actions Golang: CI Pipeline for Automated Spec Validation
How to integrate GitHub Spec Kit into GitHub Actions for a CI pipeline that validates Golang specs. Three-stage pipeline, cost control, matrix testing, and PR automation.
Integrating Spec Kit into GitHub Actions turns spec validation from a manual ritual that is easy to forget into an automated CI pipeline that runs on every PR. Instead of relying on reviewers to remember to check AC compliance one by one, we hand that repetitive work to the machine — and free up reviewer time for the decisions that genuinely require human judgment.
In this article we build a three-stage CI pipeline for a Golang project: a static spec check with no API cost, an LLM audit for AC compliance, and a standard Go quality gate. We also cover cost control, matrix testing for monorepos, PR automation, and the troubleshooting that commonly shows up when specify runs on a CI runner.
19.1 A Three-Stage CI Pipeline with Spec Kit
Before touching any YAML, it is important to understand why validation is split into three stages in a specific order. The diagram below maps the three stages along with their duration and cost, from the cheapest to the most comprehensive.
1Stage 1: Spec Check (Fast, ~2 min)
2 - Does a spec file exist for the feature branch?
3 - Is the spec syntax valid?
4 - Do all ACs have the correct format?
5 - No LLM → no API cost
6
7Stage 2: Spec Audit (LLM-powered, ~5 min)
8 - specify audit --feature [name]
9 - AC compliance check with AI analysis
10 - Architecture pattern check
11 - Score threshold enforcement
12
13Stage 3: Quality Gate (Comprehensive, ~10 min)
14 - go test -race ./...
15 - golangci-lint run ./...
16 - Coverage check ≥ 85%
17 - Build verification
18
19Total runtime: ~17 min — acceptable for a developer workflowThis ordering is no accident: the cheap, fast stage runs first so that obvious failures are caught without burning any LLM quota. Only after the spec passes the static checks do we “pay” for the deeper AI audit.
19.2 Complete Workflow: spec-ci.yml
Here is the full workflow that implements those three stages as interdependent jobs. Notice how needs and if arrange for each subsequent stage to run only when the previous stage is relevant.
1# .github/workflows/spec-ci.yml
2name: Spec CI Pipeline
3
4on:
5 pull_request:
6 branches: [main, develop]
7 push:
8 branches: [main]
9
10env:
11 GO_VERSION: '1.22'
12 NODE_VERSION: '20'
13 MIN_AUDIT_SCORE: '85'
14
15jobs:
16 # ─── Stage 1: Spec Check ──────────────────
17 spec-check:
18 name: "Stage 1: Spec File Check"
19 runs-on: ubuntu-latest
20 outputs:
21 has_spec: ${{ steps.check.outputs.has_spec }}
22 feature: ${{ steps.extract.outputs.feature }}
23
24 steps:
25 - uses: actions/checkout@v4
26
27 - name: Extract feature name
28 id: extract
29 run: |
30 BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
31 if [[ "$BRANCH" == feature/* ]]; then
32 FEATURE=$(echo "$BRANCH" | sed 's/feature\/[A-Z0-9-]*-//')
33 echo "feature=$FEATURE" >> $GITHUB_OUTPUT
34 else
35 echo "feature=" >> $GITHUB_OUTPUT
36 fi
37
38 - name: Check spec exists
39 id: check
40 run: |
41 FEATURE="${{ steps.extract.outputs.feature }}"
42 if [ -n "$FEATURE" ] && [ -d ".specify/features/$FEATURE" ]; then
43 echo "has_spec=true" >> $GITHUB_OUTPUT
44 echo "✅ Spec found: .specify/features/$FEATURE"
45
46 # Additional static checks (no API needed)
47 SPEC=".specify/features/$FEATURE/spec.md"
48
49 # Check required sections
50 for section in "Acceptance Criteria" "Edge Cases" "Out of Scope"; do
51 if ! grep -q "$section" "$SPEC"; then
52 echo "❌ Missing section: $section"
53 exit 1
54 fi
55 done
56
57 # Check minimum ACs
58 AC_COUNT=$(grep -c "^- AC" "$SPEC" || echo "0")
59 if [ "$AC_COUNT" -lt "3" ]; then
60 echo "❌ Too few ACs: $AC_COUNT (minimum: 3)"
61 exit 1
62 fi
63
64 echo "✅ Spec has $AC_COUNT ACs, all required sections present"
65 else
66 echo "has_spec=false" >> $GITHUB_OUTPUT
67 if [[ "${{ github.head_ref }}" == feature/* ]]; then
68 echo "⚠️ Feature branch without spec — consider adding a spec"
69 fi
70 fi
71
72 - name: Validate spec YAML/Markdown syntax
73 if: steps.check.outputs.has_spec == 'true'
74 run: |
75 # Install markdownlint for a basic syntax check
76 npm install -g markdownlint-cli
77 FEATURE="${{ steps.extract.outputs.feature }}"
78 markdownlint ".specify/features/$FEATURE/spec.md" \
79 --config .markdownlintrc 2>/dev/null || \
80 echo "⚠️ Minor markdown issues found (non-blocking)"
81
82 # ─── Stage 2: Spec Audit ──────────────────
83 spec-audit:
84 name: "Stage 2: LLM Spec Audit"
85 needs: spec-check
86 if: needs.spec-check.outputs.has_spec == 'true'
87 runs-on: ubuntu-latest
88
89 steps:
90 - uses: actions/checkout@v4
91
92 - name: Setup Node.js
93 uses: actions/setup-node@v4
94 with:
95 node-version: ${{ env.NODE_VERSION }}
96
97 - name: Setup Go
98 uses: actions/setup-go@v5
99 with:
100 go-version: ${{ env.GO_VERSION }}
101 cache: true
102
103 - name: Install Spec Kit
104 run: npm install -g @github/spec-kit
105
106 - name: Run spec audit
107 id: audit
108 env:
109 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
110 run: |
111 FEATURE="${{ needs.spec-check.outputs.feature }}"
112
113 # Run the audit and capture output
114 specify audit --feature "$FEATURE" --output github-summary
115
116 # Get the numerical score
117 SCORE=$(specify audit --feature "$FEATURE" --score --format number)
118 echo "score=$SCORE" >> $GITHUB_OUTPUT
119 echo "feature=$FEATURE" >> $GITHUB_OUTPUT
120
121 echo "## Spec Audit Results" >> $GITHUB_STEP_SUMMARY
122 echo "Score: **$SCORE/100**" >> $GITHUB_STEP_SUMMARY
123
124 if [ "$SCORE" -lt "$MIN_AUDIT_SCORE" ]; then
125 echo "❌ Score $SCORE < minimum $MIN_AUDIT_SCORE"
126 exit 1
127 fi
128
129 echo "✅ Spec audit passed: $SCORE/100"
130
131 - name: Post audit comment to PR
132 if: always() && github.event_name == 'pull_request'
133 uses: actions/github-script@v7
134 with:
135 script: |
136 const score = '${{ steps.audit.outputs.score }}' || 'N/A';
137 const feature = '${{ steps.audit.outputs.feature }}';
138 const minScore = '${{ env.MIN_AUDIT_SCORE }}';
139 const passed = parseInt(score) >= parseInt(minScore);
140 const icon = passed ? '✅' : '❌';
141
142 const body = [
143 '## 🔍 Spec Audit Report',
144 '',
145 `${icon} **Spec Audit Score: ${score}/100** (minimum: ${minScore})`,
146 `Feature: \`${feature}\``,
147 '',
148 passed
149 ? `All ACs verified. Code complies with the specification.`
150 : `Score below threshold. Fix spec compliance issues before merging.`,
151 '',
152 `*Run locally: \`specify audit --feature ${feature}\` for details.*`
153 ].join('\n');
154
155 await github.rest.issues.createComment({
156 issue_number: context.issue.number,
157 owner: context.repo.owner,
158 repo: context.repo.repo,
159 body
160 });
161
162 # ─── Stage 3: Quality Gate ────────────────
163 quality-gate:
164 name: "Stage 3: Quality Gate"
165 needs: [spec-check, spec-audit]
166 if: always() && needs.spec-check.result == 'success'
167 runs-on: ubuntu-latest
168
169 steps:
170 - uses: actions/checkout@v4
171
172 - uses: actions/setup-go@v5
173 with:
174 go-version: ${{ env.GO_VERSION }}
175 cache: true
176
177 - name: Build
178 run: go build ./...
179
180 - name: Test with race detector
181 run: go test -race -timeout 5m ./...
182
183 - name: Coverage check
184 run: |
185 go test -coverprofile=coverage.out ./...
186 COVERAGE=$(go tool cover -func=coverage.out | \
187 grep total | awk '{print $3}' | tr -d '%')
188 echo "Coverage: ${COVERAGE}%"
189 echo "coverage=${COVERAGE}" >> $GITHUB_OUTPUT
190
191 if (( $(echo "$COVERAGE < 85" | bc -l) )); then
192 echo "❌ Coverage ${COVERAGE}% < 85% minimum"
193 exit 1
194 fi
195 echo "✅ Coverage: ${COVERAGE}%"
196
197 - name: Lint
198 uses: golangci/golangci-lint-action@v6
199 with:
200 version: latest
201 args: --timeout=5m
202
203 - name: Summary
204 if: always()
205 run: |
206 echo "## Quality Gate Summary" >> $GITHUB_STEP_SUMMARY
207 echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY
208 echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
209 echo "| Build | ✅ |" >> $GITHUB_STEP_SUMMARY
210 echo "| Tests (race) | ✅ |" >> $GITHUB_STEP_SUMMARY
211 echo "| Coverage | ✅ |" >> $GITHUB_STEP_SUMMARY
212 echo "| Lint | ✅ |" >> $GITHUB_STEP_SUMMARYThe key to this workflow lies in the outputs from spec-check passed forward via needs — the feature name is extracted once from the branch name, then reused in the audit stage with no duplicated logic. That way a single YAML file is enough to enforce spec-first discipline end to end.
19.3 Fast Spec Check without LLM (< 30 seconds)
For more instant feedback on pre-push or staging branches, we can run a heftier static-check variant without calling the LLM at all. The snippet below checks required sections, the minimum AC count, clarification status, and the presence of a plan.
1- name: Fast spec check (no API)
2 run: |
3 FEATURE="${FEATURE}"
4 SPEC=".specify/features/$FEATURE/spec.md"
5
6 echo "--- Fast Spec Check ---"
7
8 # 1. Required sections
9 SECTIONS=("Acceptance Criteria" "Edge Cases" "Out of Scope" "User Stories")
10 for s in "${SECTIONS[@]}"; do
11 if grep -q "$s" "$SPEC"; then
12 echo "✅ Section: $s"
13 else
14 echo "❌ Missing: $s"
15 FAIL=1
16 fi
17 done
18
19 # 2. Minimum AC count
20 AC_COUNT=$(grep -cE "^[-*] AC[0-9]+" "$SPEC" || echo "0")
21 if [ "$AC_COUNT" -lt "5" ]; then
22 echo "❌ Too few ACs: $AC_COUNT (minimum: 5)"
23 FAIL=1
24 else
25 echo "✅ ACs: $AC_COUNT"
26 fi
27
28 # 3. Clarifications answered (if any)
29 CLARI=".specify/features/$FEATURE/clarifications.md"
30 if [ -f "$CLARI" ]; then
31 UNANSWERED=$(grep -cE "^Q[0-9]+:" "$CLARI" || echo "0")
32 ANSWERED=$(grep -cE "^A[0-9]+:" "$CLARI" || echo "0")
33 if [ "$UNANSWERED" -gt "$ANSWERED" ]; then
34 echo "⚠️ Unanswered clarifications: $(($UNANSWERED - $ANSWERED))"
35 else
36 echo "✅ All clarifications answered"
37 fi
38 fi
39
40 # 4. Plan file exists
41 if [ -f ".specify/features/$FEATURE/plan.md" ]; then
42 echo "✅ Plan exists"
43 else
44 echo "⚠️ Plan not yet created"
45 fi
46
47 if [ "$FAIL" = "1" ]; then exit 1; fi
48 echo "✅ Fast spec check passed"Because it relies only on grep and file checks, this step finishes in seconds and is free — ideal as a first gate that filters out trivial mistakes before the paid LLM stage is invoked.
19.4 Cost Control in CI
The audit stage uses the LLM API, so running it on every push with no filter would waste money. The step below proactively decides when the LLM audit truly needs to run, based on the branch type and the files that changed.
1# Only run the LLM audit if there are relevant changes
2- name: Determine if LLM audit needed
3 id: should-audit
4 run: |
5 BRANCH="${{ github.head_ref }}"
6
7 # Only for feature branches
8 if [[ "$BRANCH" != feature/* ]]; then
9 echo "run=false" >> $GITHUB_OUTPUT
10 echo "Reason: not a feature branch"
11 exit 0
12 fi
13
14 # Check whether the spec or implementation changed
15 SPEC_CHANGED=$(git diff --name-only origin/${{ github.base_ref }}... | \
16 grep -c "\.specify/" || echo "0")
17 IMPL_CHANGED=$(git diff --name-only origin/${{ github.base_ref }}... | \
18 grep -cE "^internal/|^cmd/" || echo "0")
19
20 if [ "$SPEC_CHANGED" -gt "0" ] || [ "$IMPL_CHANGED" -gt "0" ]; then
21 echo "run=true" >> $GITHUB_OUTPUT
22 echo "Reason: spec or impl changed ($SPEC_CHANGED spec, $IMPL_CHANGED impl files)"
23 else
24 echo "run=false" >> $GITHUB_OUTPUT
25 echo "Reason: no spec or impl changes detected"
26 fi
27
28- name: Run LLM audit (conditional)
29 if: steps.should-audit.outputs.run == 'true'
30 env:
31 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
32 run: specify audit --feature "$FEATURE"With gating like this, a PR that only touches documentation or configuration does not trigger the AI audit — LLM cost is only incurred when there is a spec or implementation change that actually warrants validation.
19.5 Branch-Based Workflow
Not every branch needs the same level of audit: a PR to develop only needs a light audit, whereas a PR to main deserves a comprehensive one. The reusable workflow below accepts an audit_mode parameter so that one definition can be used with different intensities.
1# Reusable workflow for spec check
2# .github/workflows/reusable-spec-check.yml
3on:
4 workflow_call:
5 inputs:
6 feature:
7 required: true
8 type: string
9 audit_mode:
10 required: false
11 type: string
12 default: "basic" # basic | comprehensive | fast
13
14jobs:
15 audit:
16 runs-on: ubuntu-latest
17 steps:
18 - uses: actions/checkout@v4
19 - run: npm install -g @github/spec-kit
20 - name: Run audit
21 env:
22 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
23 run: |
24 MODE="${{ inputs.audit_mode }}"
25 FEATURE="${{ inputs.feature }}"
26
27 case "$MODE" in
28 comprehensive) specify audit --feature "$FEATURE" --comprehensive ;;
29 fast) specify audit --feature "$FEATURE" --fast ;;
30 *) specify audit --feature "$FEATURE" ;;
31 esacOnce the reusable workflow is defined, the caller only needs to pick the mode dynamically based on the target branch. The conditional expression below maps main to comprehensive mode and everything else to basic.
1# PR to develop: basic audit
2# PR to main: comprehensive audit
3- uses: ./.github/workflows/reusable-spec-check.yml
4 with:
5 feature: ${{ env.FEATURE }}
6 audit_mode: ${{ github.base_ref == 'main' && 'comprehensive' || 'basic' }}This pattern keeps cost proportional to risk: changes headed for production get full scrutiny, while day-to-day iteration stays light and fast.
19.6 Matrix Testing for Monorepos
In a monorepo with many services, auditing all of them on every PR is clearly wasteful. The workflow below detects which services changed via paths-filter, then runs the audit only for those services as a matrix.
1jobs:
2 detect-changed:
3 outputs:
4 services: ${{ steps.detect.outputs.services }}
5 steps:
6 - uses: dorny/paths-filter@v3
7 id: detect
8 with:
9 list-files: json
10 filters: |
11 order: ['services/order-service/**']
12 product: ['services/product-service/**']
13 notification: ['services/notification-service/**']
14
15 - id: detect
16 run: |
17 SERVICES=$(echo '${{ toJSON(steps.filter.outputs) }}' | \
18 python3 -c "
19 import json, sys
20 filters = json.load(sys.stdin)
21 changed = [k for k,v in filters.items() if v == 'true']
22 print(json.dumps(changed))
23 ")
24 echo "services=$SERVICES" >> $GITHUB_OUTPUT
25
26 audit-services:
27 needs: detect-changed
28 if: needs.detect-changed.outputs.services != '[]'
29 strategy:
30 matrix:
31 service: ${{ fromJson(needs.detect-changed.outputs.services) }}
32 fail-fast: false # Audit all services even if one fails
33
34 steps:
35 - uses: actions/checkout@v4
36 - run: npm install -g @github/spec-kit
37 - name: Audit ${{ matrix.service }}
38 env:
39 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
40 run: |
41 cd services/${{ matrix.service }}
42 FEATURE=$(extract_feature_from_branch)
43 specify audit --feature "$FEATURE" || true # Non-blocking for nowfail-fast: false ensures that a failure in one service does not cancel the audits of the others — you get the complete picture in a single run instead of having to retrigger repeatedly.
19.7 Spec Status Badge in the README
An audit score has more impact when it is visible in the README as an always-updated badge. The workflow below computes the average score across all features on push to main, then rewrites the badge in the README automatically.
1# .github/workflows/spec-badge.yml
2on:
3 push:
4 branches: [main]
5
6jobs:
7 update-badge:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 - run: npm install -g @github/spec-kit
12 - name: Get overall score
13 env:
14 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
15 run: |
16 AVG_SCORE=$(specify audit --all --score --format json | \
17 python3 -c "
18 import json, sys
19 scores = json.load(sys.stdin)
20 avg = sum(s['score'] for s in scores) / len(scores)
21 print(int(avg))
22 ")
23
24 COLOR="green"
25 [ "$AVG_SCORE" -lt "90" ] && COLOR="yellow"
26 [ "$AVG_SCORE" -lt "85" ] && COLOR="red"
27
28 # Update badge in README
29 sed -i "s|spec-audit-[0-9]*%25-[a-z]*|spec-audit-${AVG_SCORE}%25-${COLOR}|g" README.md
30
31 git config --global user.email "ci@santekno.com"
32 git config --global user.name "CI Bot"
33 git commit -am "chore: update spec audit badge [skip ci]" || true
34 git push || trueThis automatic badge makes spec quality a metric that the whole team can see — a subtle but effective social nudge that keeps scores high.
19.8 Spec Audit PR Decoration
Beyond a badge, we can enrich the PR itself with labels and a title that reflect the audit score. The script below adds labels according to score ranges and injects the score into the PR title.
1- name: Generate PR decoration
2 if: github.event_name == 'pull_request'
3 uses: actions/github-script@v7
4 with:
5 script: |
6 const feature = '${{ env.FEATURE }}';
7 const score = parseInt('${{ steps.audit.outputs.score }}') || 0;
8 const minScore = parseInt('${{ env.MIN_AUDIT_SCORE }}');
9
10 // Update PR labels
11 const labels = [];
12 if (score >= 95) labels.push('spec-excellent');
13 else if (score >= 90) labels.push('spec-pass');
14 else if (score >= 85) labels.push('spec-warning');
15 else labels.push('spec-fail');
16
17 await github.rest.issues.addLabels({
18 issue_number: context.issue.number,
19 owner: context.repo.owner,
20 repo: context.repo.repo,
21 labels
22 });
23
24 // Update PR title with a score badge
25 const pr = await github.rest.pulls.get({
26 pull_number: context.issue.number,
27 owner: context.repo.owner,
28 repo: context.repo.repo,
29 });
30
31 const icon = score >= minScore ? '✅' : '⚠️';
32 const titleWithScore = pr.data.title.includes('[spec:')
33 ? pr.data.title.replace(/\[spec:[^\]]*\]/, `[spec:${score}]`)
34 : `${pr.data.title} [spec:${score}]`;
35
36 await github.rest.pulls.update({
37 pull_number: context.issue.number,
38 owner: context.repo.owner,
39 repo: context.repo.repo,
40 title: titleWithScore
41 });With this decoration, a PR’s compliance status can be read at a glance from the PR list without opening the details — speeding up reviewer triage when the PR queue piles up.
19.9 Caching for Spec Kit
Installing Spec Kit and running the audit from scratch on every run wastes time. The cache configuration below stores npx artifacts and audit results, with a key that is invalidated only when spec files change.
1- name: Cache Spec Kit
2 uses: actions/cache@v4
3 with:
4 path: ~/.npm/_npx
5 key: spec-kit-${{ runner.os }}-${{ hashFiles('.speckit-version') }}
6
7- name: Cache spec audit results
8 uses: actions/cache@v4
9 with:
10 path: .specify/audit/.cache
11 key: audit-${{ runner.os }}-${{ hashFiles('.specify/**/*.md') }}
12 restore-keys: |
13 audit-${{ runner.os }}-Because the cache key is hashed from the contents of .specify/**/*.md, a commit that does not touch the specs will hit the cache immediately — trimming run duration while also cutting repeated API calls for the same content.
19.10 Handling Secrets and Security
ANTHROPIC_API_KEY is a sensitive credential that must never leak into logs. The step below validates the key’s availability without ever printing its value, and shows how to bind a job to a specific environment.
1# Don't expose the API key in logs
2- name: Validate API key available
3 run: |
4 if [ -z "$ANTHROPIC_API_KEY" ]; then
5 echo "❌ ANTHROPIC_API_KEY not set"
6 echo "Add secret: Settings → Secrets → Actions → ANTHROPIC_API_KEY"
7 exit 1
8 fi
9 echo "✅ API key configured (${#ANTHROPIC_API_KEY} chars)"
10 env:
11 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
12
13# Key rotation: use a repo environment if you need a per-environment key
14environment: staging # or productionNote the ${#ANTHROPIC_API_KEY} trick that only prints the string length, not its contents — enough to confirm the secret is loaded without leaking it into CI logs that many people can read.
19.11 Slack Notification after CI
A spec CI failure on main — which means it already got past merge — is a serious signal that needs immediate attention. The step below sends a Slack notification only when CI fails on the main branch.
1- name: Notify Slack on spec failure
2 if: failure() && github.ref == 'refs/heads/main'
3 uses: slackapi/slack-github-action@v1.27.0
4 with:
5 channel-id: 'dev-alerts'
6 slack-message: |
7 ❌ Spec CI Failed on main!
8 PR: ${{ github.event.pull_request.title }}
9 Author: ${{ github.actor }}
10 Score: ${{ steps.audit.outputs.score }}/100
11 Link: ${{ github.event.pull_request.html_url }}
12 env:
13 SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}The condition failure() && github.ref == 'refs/heads/main' keeps the channel from being flooded with notifications from ordinary PR failures — only genuinely critical incidents trigger an alert.
19.12 Complete .github Structure for a Spec Kit Project
To keep all the workflows above organized, we need a clear .github/ directory structure. The tree below shows the split between the main workflow, scheduled workflows, and templates.
1.github/
2├── workflows/
3│ ├── spec-ci.yml ← Main CI pipeline (this article)
4│ ├── spec-audit-weekly.yml ← Scheduled weekly audit
5│ ├── spec-badge.yml ← Update README badge
6│ ├── spec-pr-cleanup.yml ← Close stale spec PRs
7│ └── reusable-spec-check.yml ← Reusable workflow
8├── pull_request_template.md ← Template with spec reference
9├── ISSUE_TEMPLATE/
10│ └── spec-deviation.md ← Template for reporting spec issues
11└── CODEOWNERS ← Review rules for spec filesThe structure above separates concerns cleanly: one workflow per responsibility, making it easy to disable or debug each independently. One of the key files there is CODEOWNERS, which enforces who must review spec changes.
1# CODEOWNERS
2# .specify/constitution.md needs tech lead approval
3.specify/constitution.md @tech-lead @architecture-board
4
5# Feature specs need senior dev approval
6.specify/features/ @senior-devs
7
8# Event contracts need approval from all affected team leads
9.specify/contracts/ @order-team-lead @notification-team-leadWith CODEOWNERS, changes to the constitution or an event contract automatically require the right reviewers — governance enforced by tooling, not dependent on anyone’s memory.
19.13 Emergency Override: Skip Spec Check
Sometimes there is an urgent hotfix that cannot wait for the full spec process. The bypass mechanism below allows a skip, but only when there is an explicit marker in the PR body and approval from a tech lead.
1# In .github/workflows/spec-ci.yml
2- name: Check for emergency bypass
3 id: bypass-check
4 run: |
5 PR_BODY="${{ github.event.pull_request.body }}"
6
7 if echo "$PR_BODY" | grep -q "\[emergency-bypass\]"; then
8 # Check whether there is approval from a tech lead
9 APPROVALS=$(gh pr view ${{ github.event.pull_request.number }} \
10 --json reviews --jq '.reviews[] | select(.state=="APPROVED") | .author.login' | \
11 grep -c "tech-lead\|architecture-board" || echo "0")
12
13 if [ "$APPROVALS" -gt "0" ]; then
14 echo "bypass=true" >> $GITHUB_OUTPUT
15 echo "⚠️ Emergency bypass approved by tech lead"
16 else
17 echo "bypass=false" >> $GITHUB_OUTPUT
18 echo "❌ Emergency bypass requires tech lead approval"
19 exit 1
20 fi
21 else
22 echo "bypass=false" >> $GITHUB_OUTPUT
23 fiThe design key here is a bypass that remains controlled: not a free-for-all emergency button, but a door that only opens with explicit approval — so the fast lane never turns into an abused loophole.
19.14 Tracking CI Metrics
To understand spec-quality trends over time, send the audit score as metrics to an observability platform. The step below sends the score, feature, and job status to Datadog at the end of every run.
1# Send metrics to Datadog after every CI run
2- name: Send CI metrics
3 if: always()
4 run: |
5 SCORE="${{ steps.audit.outputs.score || '0' }}"
6 FEATURE="${{ env.FEATURE }}"
7 STATUS="${{ job.status }}"
8
9 # Datadog metrics via API
10 curl -X POST "https://api.datadoghq.com/api/v1/series" \
11 -H "DD-API-KEY: ${{ secrets.DATADOG_API_KEY }}" \
12 -H "Content-Type: application/json" \
13 -d "{
14 \"series\": [{
15 \"metric\": \"spec_kit.ci.audit_score\",
16 \"points\": [[$(date +%s), $SCORE]],
17 \"tags\": [\"feature:$FEATURE\", \"branch:${{ github.ref }}\", \"status:$STATUS\"]
18 }]
19 }"With the audit score flowing into a dashboard, you can see whether spec quality improves as the team adopts it or actually declines — data that becomes fact-based retrospective material, not opinion.
19.15 Troubleshooting CI Issues
Running specify on a CI runner often surfaces problems that never appear locally. Here are the four most common obstacles along with their fixes, starting with a command not found after global installation.
1# Problem 1: "specify: command not found" in CI
2- name: Install Spec Kit
3 run: |
4 npm install -g @github/spec-kit
5 # Verify
6 specify --version
7 echo "$(npm root -g)/.bin" >> $GITHUB_PATHThe second problem is an API timeout that fails the job even though the spec is perfectly fine. The fix is to wrap the audit in a time-bounded retry mechanism.
1# Problem 2: API timeout in CI
2- name: Run audit with retry
3 uses: nick-fields/retry@v3
4 with:
5 timeout_minutes: 10
6 max_attempts: 2
7 command: specify audit --feature $FEATURE --timeout 300Third, when many PRs are audited at the same time you can hit a rate limit from Anthropic. The retry loop with exponential backoff below adds a progressively longer pause on each attempt.
1# Problem 3: Rate limit from Anthropic
2- name: Audit with rate limit handling
3 env:
4 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
5 run: |
6 # Retry with exponential backoff
7 for i in 1 2 3; do
8 specify audit --feature "$FEATURE" && break
9 echo "Attempt $i failed, waiting $((i*30))s..."
10 sleep $((i*30))
11 doneFinally, an audit score that differs between local and CI is usually caused by an out-of-sync Spec Kit version. The commands below help align the version across both environments.
1# Problem 4: Audit score differs between local and CI
2# Make sure the versions match
3specify --version # local
4# vs
5echo "npm install -g @github/spec-kit@$(specify --version | cut -d' ' -f2)"
6# Pin the version in CIThe common thread across these four problems is the same: treat the CI runner as an environment different from your laptop — pin versions, add retries, and verify the PATH explicitly so the results are reproducible.
19.16 Cost Estimation for CI
Before enforcing the audit across the whole team, it is fair to worry about cost. The estimate below breaks down the monthly cost for a team of 10 developers along with a comparison to the value it saves.
1Estimated Spec Kit cost in CI per month
2(assumptions: 10 developers, 4 PRs per dev per sprint, 2 sprints per month)
3
4Total PRs per month: 10 × 4 × 2 = 80 PRs
5
6Per PR:
7- Stage 2 (LLM audit): ~$0.05-0.10
8- Weekly audit (shared): ~$0.50 per week
9
10Monthly estimate:
11- PR audits: 80 × $0.075 avg = $6.00
12- Weekly audits: 4 × $0.50 = $2.00
13- Total: ~$8/month
14
15Value of 80 PRs audited:
16- Assume 20% of PRs have a spec issue (16 PRs)
17- Cost per post-deploy fix: 4 hrs × $80 = $320
18- Value if 50% caught: 8 × $320 = $2,560
19- ROI: $2,560 saved / $8 cost = 320xThis 320x ROI figure explains why cost is not a valid reason to delay adoption: the audit cost is almost negligible compared to the cost of fixing a single spec bug that slips into production.
19.17 Integration with Status Checks
For the audit to be genuinely binding rather than merely informative, make all three stages required status checks in branch protection. The configuration below states which checks must be green before a merge is allowed.
1# Add the spec audit as a required status check in GitHub
2# Settings → Branches → Branch protection rules → Require status checks
3
4required_status_checks:
5 - "Stage 1: Spec File Check"
6 - "Stage 2: LLM Spec Audit"
7 - "Stage 3: Quality Gate"
8
9# Without all three status checks passing, the PR cannot be mergedWithout a required status check, a developer in a hurry will be tempted to skip the audit — and it is this enforcement at the branch-protection level that turns SDD from a “recommendation” into a “non-negotiable standard”.
19.18 PR Template for a Spec-Driven Project
Complementing the automated enforcement, a PR template helps developers include spec context consistently. The template below provides fields for the spec reference, audit results, and a merge-readiness checklist.
1<!-- .github/pull_request_template.md -->
2
3## Summary
4[Short description of the feature or change]
5
6## Spec Reference
7- **Spec**: `.specify/features/[feature]/spec.md` (v[x.x])
8- **Plan**: `.specify/features/[feature]/plan.md`
9- **Spec approved by**: @[reviewer] on [date]
10
11## Spec Audit
12Run before the PR: `specify audit --feature [feature]`
13- Audit score: [score]/100
14- ACs: [n]/[total] implemented
15- EC handling: [status]
16
17## Type of Change
18- [ ] New feature (spec required)
19- [ ] Bug fix (hotfix — spec optional)
20- [ ] Refactoring (spec optional)
21- [ ] Documentation
22- [ ] Other: [describe]
23
24## Checklist
25- [ ] All ACs implemented
26- [ ] Tests cover all ACs
27- [ ] `specify audit --score` ≥ 90
28- [ ] `go test -race` passes
29- [ ] Coverage ≥ 85%
30- [ ] `golangci-lint` clean
31- [ ] CLAUDE.md updated (if new patterns)
32- [ ] `.specify/` committed with the feature
33
34## Emergency Bypass (if urgent hotfix)
35- [ ] Add `[emergency-bypass]` to the PR body
36- [ ] Get tech lead approval before mergeThis template aligns expectations between author and reviewer from the moment the PR is opened — the reviewer knows exactly where to find the spec, and the author is reminded of the very checklist enforced by CI.
19.19 Tips & Gotchas
💡 Tip 1: Use required status checks
If the spec audit is not required, developers will skip it. Enforce it via branch protection.
💡 Tip 2: Fast check first, LLM check later
Stage 1 (fast) catches obvious problems in 30 seconds. This saves LLM cost — if Stage 1 fails, the LLM never has to be called.
💡 Tip 3: Cache spec audit results
The same audit on the same commit does not need to run again. Cache based on the hash of the .specify/ folder.
💡 Tip 4: Alert to Slack for failures on main
A spec CI failure on main (after merge) is a serious signal — it needs an immediate notification.
⚠️ Gotcha 1: API rate limit in parallel CI
If many PRs are audited at once, you can hit an Anthropic rate limit. To queue rather than cancel a running job, use a concurrency group like the one below.
1concurrency:
2 group: spec-audit-${{ github.actor }}
3 cancel-in-progress: false # Don't cancel, just queueWith cancel-in-progress: false, audits arriving simultaneously are queued one by one — preventing a spike of API calls that triggers a rate limit, without losing any audit result.
⚠️ Gotcha 2: ANTHROPIC_API_KEY must be a secret, not an env
Don’t hardcode the API key in the workflow file. Always use ${{ secrets.ANTHROPIC_API_KEY }}.
⚠️ Gotcha 3: CI context differs from local
specify audit in CI may produce a different result from local if CLAUDE.md is not identical. Make sure CLAUDE.md is committed.
⚠️ Gotcha 4: Auditing a monorepo can be slow
specify audit --all for 10+ features can take 30+ minutes. Use path-based filtering to audit only what changed.
19.20 Summary
Spec Kit + GitHub Actions delivers a robust three-stage pipeline:
Stage 1 (Spec Check, ~2 min): Static validation without an LLM — verify the spec file exists, the format is valid, and the sections are complete. Free.
Stage 2 (Spec Audit, ~5 min): LLM-powered AC compliance check — verify that every acceptance criterion is implemented. ~$0.05-0.10 per PR.
Stage 3 (Quality Gate, ~10 min): Standard Go quality checks — race condition test, coverage, lint.
Total runtime: ~17 min. Total cost per PR: ~$0.10. ROI: 320x from preventing post-deploy fixes.
Required status checks ensure the spec audit cannot be bypassed — consistent enforcement without manual friction.
In the final article of Topic 2, we see how all of Spec Kit’s outputs — spec, plan, tasks, contracts — become a formal contract in a more advanced AI pipeline, paving the way to Topics 3–10.