Some checks failed
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 22s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
Pins @stoplight/spectral-cli@6.16.0 and caches ~/.npm keyed on that version, so Spectral is fetched once and reused across runs instead of re-downloaded each time. A version bump busts the cache key deterministically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
7.7 KiB
YAML
170 lines
7.7 KiB
YAML
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-041). 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-041.
|
|
|
|
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<<EOF"
|
|
echo "### ⚠️ Constitution changed — Sync Impact review"
|
|
echo ""
|
|
echo "\`.specify/constitution.md\` was modified in this PR. Per its §6 Sync Impact rule, re-read and reconcile every file below, and confirm the semantic version bump:"
|
|
echo ""
|
|
while IFS= read -r line; do echo "- \`${line#./}\`"; done < /tmp/refs.txt
|
|
echo "EOF"
|
|
} >> "$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"; }
|