name: SDD Gate # Spec-Driven Development quality gate. Runs on PRs and validates the SDD artifacts that # the PR touches. The first three jobs are NON-BLOCKING for now (continue-on-error) so the # team can adopt the workflow without CI immediately failing. # # TODO: flip spec-lint, contract-validate, and traceability-check to BLOCKING # (remove `continue-on-error: true`) once SDD adoption has settled — target: after the # first 5 real features have shipped through the .specify/ workflow. Tracked in ADR-041. on: pull_request: jobs: # ─── Spec structure lint ────────────────────────────────────────────────────── # Every modified .specify/features/*/spec.md must contain the required SDD sections # and at least one REQ-NNN requirement. Pure grep — no external tooling. spec-lint: name: Spec Lint runs-on: ubuntu-latest continue-on-error: true # TODO: remove to make blocking (see header) steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Lint changed spec.md files 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 required=( "## Context & Why" "## Requirements" "## Acceptance Criteria" "## Out of Scope" "## Security Considerations" "## Traceability" ) # Self-test: a fixture with all sections + a REQ id must pass the section check. fixture="$(mktemp)" { for s in "${required[@]}"; do echo "$s"; done; echo "- **REQ-001** (Ubiquitous) — The x shall y."; } > "$fixture" for s in "${required[@]}"; do grep -qF "$s" "$fixture" || { echo "FAIL: spec-lint self-test missing '$s'"; exit 1; } done changed="$(git diff --name-only "$base"...HEAD -- '.specify/features/*/spec.md' || true)" if [ -z "$changed" ]; then echo "No spec.md files changed — nothing to lint." exit 0 fi rc=0 for f in $changed; do [ -f "$f" ] || continue # deleted file echo "── linting $f" for s in "${required[@]}"; do if ! grep -qF "$s" "$f"; then echo "::error file=$f::missing required section '$s'" rc=1 fi done if ! grep -qE 'REQ-[0-9]{3}' "$f"; then echo "::error file=$f::no REQ-NNN requirement found" rc=1 fi done exit $rc # ─── Contract validation ────────────────────────────────────────────────────── # Validate any modified api-contract.yaml with Spectral (OpenAPI 3.1). REST stack — # no GraphQL. Skips cleanly when no contract 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' - name: Lint changed OpenAPI contracts 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 changed="$(git diff --name-only "$base"...HEAD -- '.specify/features/*/api-contract.yaml' || true)" if [ -z "$changed" ]; then echo "No api-contract.yaml changed — nothing to validate." exit 0 fi rc=0 for f in $changed; do [ -f "$f" ] || continue echo "── spectral lint $f" npx --yes @stoplight/spectral-cli@6 lint "$f" || rc=1 done exit $rc # ─── Traceability check ─────────────────────────────────────────────────────── # Warn (non-blocking) when a changed spec.md references a REQ-NNN that is absent from # .specify/rtm.md. Keeps the matrix honest without hard-failing during adoption. traceability-check: name: Traceability Check runs-on: ubuntu-latest continue-on-error: true # TODO: remove to make blocking (see header) steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Cross-check spec REQ-IDs against rtm.md 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 changed="$(git diff --name-only "$base"...HEAD -- '.specify/features/*/spec.md' || true)" [ -z "$changed" ] && { echo "No spec.md changed."; exit 0; } test -f .specify/rtm.md || { echo "::warning::.specify/rtm.md missing"; exit 0; } missing=0 for f in $changed; do [ -f "$f" ] || continue for req in $(grep -oE 'REQ-[0-9]{3}' "$f" | sort -u); do if ! grep -qF "$req" .specify/rtm.md; then echo "::warning file=$f::$req is not present in .specify/rtm.md" missing=$((missing+1)) fi done done echo "$missing REQ-ID(s) missing from rtm.md (warning only)." # ─── 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):" # rg-free: grep recursively, excluding the file itself and VCS/build dirs. 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: # Gitea Actions exposes the repo token as GITHUB_TOKEN; the API is Gitea-compatible. 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"; }