name: SDD Gate # Spec-Driven Development quality gate. Runs on PRs. # # This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed # spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD # artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution. # # The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the # workflow without CI immediately failing. # # TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`) # once SDD adoption has settled — target: after the first 5 features have shipped through # the workflow. Tracked in ADR-042. on: pull_request: jobs: # ─── RTM check ──────────────────────────────────────────────────────────────── # The Requirements Traceability Matrix is the one per-feature SDD artifact in git. Every # data row must point at a Gitea issue (`#n`) and name at least one test. Warn otherwise. # Pure awk — no external tooling. Columns: | REQ-ID | Summary | Issue | Feature | Impl | Test | Status | rtm-check: name: RTM Check runs-on: ubuntu-latest continue-on-error: true # TODO: remove to make blocking (see header) steps: - uses: actions/checkout@v4 - name: Validate .specify/rtm.md rows shell: bash run: | set -uo pipefail rtm=".specify/rtm.md" test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; } # Self-test: a good row passes, a row with an empty Issue or Test is flagged. check_row() { awk -F'|' '{ issue=$4; test_col=$7; gsub(/^[ \t]+|[ \t]+$/,"",issue); gsub(/^[ \t]+|[ \t]+$/,"",test_col); if (issue !~ /#/ || test_col=="") exit 1; else exit 0 }'; } echo '| REQ-001 | x | #42 | f | impl | SomeTest#works | Done |' | check_row \ || { echo "FAIL: rtm-check self-test rejected a valid row"; exit 1; } echo '| REQ-002 | x | | f | impl | | Planned |' | check_row \ && { echo "FAIL: rtm-check self-test accepted an empty row"; exit 1; } bad=0 while IFS= read -r line; do echo "$line" | check_row || { req=$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}') echo "::warning file=$rtm::row $req is missing an Issue (#n) or a Test" bad=$((bad+1)) } done < <(grep -E '^\| REQ-[0-9]{3} ' "$rtm") echo "$bad RTM row(s) incomplete (warning only)." # ─── Contract validation ────────────────────────────────────────────────────── # Validate any committed OpenAPI contract with Spectral (OpenAPI 3.1). REST stack — no # GraphQL. Contracts are optional and ride a feature branch when present; the _example one # is always linted. Skips cleanly when none changed. contract-validate: name: Contract Validate runs-on: ubuntu-latest continue-on-error: true # TODO: remove to make blocking (see header) steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: '24' # Cache the npm/npx download so Spectral isn't re-fetched every run. The key is pinned to # the exact Spectral version below, so a version bump busts the cache deterministically. - name: Cache Spectral (npm cache) uses: actions/cache@v4 with: path: ~/.npm key: spectral-cli-6.16.0 restore-keys: spectral-cli- - name: Lint changed OpenAPI contracts shell: bash env: SPECTRAL: "@stoplight/spectral-cli@6.16.0" # pinned — keep in sync with the cache key above run: | set -uo pipefail base="origin/${{ github.event.pull_request.base.ref }}" git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true # Any *.yaml under .specify/ or any file named like a contract. changed="$(git diff --name-only "$base"...HEAD -- '.specify/**/*.yaml' '**/api-contract.yaml' '**/*.openapi.yaml' || true)" if [ -z "$changed" ]; then echo "No OpenAPI contract changed — nothing to validate." exit 0 fi rc=0 for f in $changed; do [ -f "$f" ] || continue echo "── spectral lint $f" npx --yes "$SPECTRAL" lint "$f" || rc=1 done exit $rc # ─── Constitution change impact ─────────────────────────────────────────────── # When .specify/constitution.md is modified, list every file that references it (and so # may need a Sync Impact update) and post it as a PR comment. Best-effort: if no token is # available the list is only echoed to the log. This job is informational, never blocking. constitution-diff: name: Constitution Impact runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: List files referencing the constitution id: impact shell: bash run: | set -uo pipefail base="origin/${{ github.event.pull_request.base.ref }}" git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true if ! git diff --name-only "$base"...HEAD -- '.specify/constitution.md' | grep -q .; then echo "constitution.md not modified — skipping." echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "changed=true" >> "$GITHUB_OUTPUT" echo "Files referencing constitution.md (review for Sync Impact):" grep -rIl --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=target \ -e 'constitution.md' -e 'constitution §' . \ | grep -v '^\./.specify/constitution.md$' | sort > /tmp/refs.txt || true cat /tmp/refs.txt { echo "body<> "$GITHUB_OUTPUT" - name: Post PR comment (best-effort) if: steps.impact.outputs.changed == 'true' shell: bash env: TOKEN: ${{ secrets.GITHUB_TOKEN }} SERVER: ${{ github.server_url }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} BODY: ${{ steps.impact.outputs.body }} run: | set -uo pipefail if [ -z "${TOKEN:-}" ]; then echo "No token available — printing impact list to log only:" echo "$BODY" exit 0 fi payload="$(jq -n --arg b "$BODY" '{body:$b}')" curl -sS -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ "${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" \ -d "$payload" >/dev/null \ && echo "Posted Sync Impact comment to PR #${PR}." \ || { echo "Comment POST failed (non-fatal); impact list:"; echo "$BODY"; }