refactor(sdd): make the feature spec issue-only (no committed spec.md)
The Gitea issue body is the single source of truth for a spec; the only per-feature artifact in git is the RTM row (REQ-ID -> issue # -> test). Drops per-feature spec.md/tasks.md/checklist files from the workflow (the _example stays as a template/reference). Updates the guide, ADR-041, AGENTS.md, CLAUDE.md, templates, the RTM (adds an Issue column), the implement/review-pr skills, and replaces the file-spec CI jobs with an rtm-check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,78 +1,64 @@
|
||||
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.
|
||||
# Spec-Driven Development quality gate. Runs on PRs.
|
||||
#
|
||||
# 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.
|
||||
# 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:
|
||||
# ─── 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
|
||||
# ─── 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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Lint changed spec.md files
|
||||
- name: Validate .specify/rtm.md rows
|
||||
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
|
||||
rtm=".specify/rtm.md"
|
||||
test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; }
|
||||
|
||||
required=(
|
||||
"## Context & Why"
|
||||
"## Requirements"
|
||||
"## Acceptance Criteria"
|
||||
"## Out of Scope"
|
||||
"## Security Considerations"
|
||||
"## Traceability"
|
||||
)
|
||||
# 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; }
|
||||
|
||||
# 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
|
||||
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 modified api-contract.yaml with Spectral (OpenAPI 3.1). REST stack —
|
||||
# no GraphQL. Skips cleanly when no contract changed.
|
||||
# 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
|
||||
@@ -92,9 +78,10 @@ jobs:
|
||||
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)"
|
||||
# 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 api-contract.yaml changed — nothing to validate."
|
||||
echo "No OpenAPI contract changed — nothing to validate."
|
||||
exit 0
|
||||
fi
|
||||
rc=0
|
||||
@@ -105,39 +92,6 @@ jobs:
|
||||
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
|
||||
@@ -164,7 +118,6 @@ jobs:
|
||||
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
|
||||
@@ -183,7 +136,6 @@ jobs:
|
||||
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 }}
|
||||
|
||||
Reference in New Issue
Block a user