Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
07 Sep 2026 · 25 min read ·Article 39 / 208
Go

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.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

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.

text
 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 workflow

This 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.

yaml
  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_SUMMARY

The 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.

yaml
 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.

yaml
 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.

yaml
 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          esac

Once 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.

yaml
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.

yaml
 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 now

fail-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.

yaml
 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 || true

This 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.

yaml
 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.

yaml
 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.

yaml
 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 production

Note 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.

yaml
 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.

text
 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 files

The 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.

text
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-lead

With 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.

yaml
 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    fi

The 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.

yaml
 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.

yaml
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_PATH

The 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.

yaml
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 300

Third, 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.

yaml
 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    done

Finally, 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.

bash
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 CI

The 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.

text
 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 = 320x

This 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.

yaml
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 merged

Without 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.

markdown
 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 merge

This 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.

yaml
1concurrency:
2  group: spec-audit-${{ github.actor }}
3  cancel-in-progress: false  # Don't cancel, just queue

With 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.

Related Articles

💬 Comments