Files
familienarchiv/.gitea/workflows/sdd-gate.yml
Marcel a904590843 feat(sdd): add Gitea issue templates and SDD CI gate
Adds .gitea/ISSUE_TEMPLATE/{feature,bug}.md (the feature template mirrors the
EARS feature-spec) and .gitea/workflows/sdd-gate.yml — spec-lint, contract
validation (Spectral), traceability check (all non-blocking during adoption),
and a constitution-impact PR comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00

207 lines
8.8 KiB
YAML

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<<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:
# 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"; }