Skip to content

Fix: complete SpecSync 5.1 lifecycle evidence hardening #836

Fix: complete SpecSync 5.1 lifecycle evidence hardening

Fix: complete SpecSync 5.1 lifecycle evidence hardening #836

Workflow file for this run

name: CI
permissions:
contents: read
pull-requests: write
checks: write
on:
push:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- 'specs/**'
- '.specsync/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'action.yml'
- 'examples/**'
- 'fledge.toml'
- 'site/**'
- 'vscode-extension/**'
- '.github/scripts/**'
- '.github/workflows/ci.yml'
pull_request:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- 'specs/**'
- '.specsync/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'action.yml'
- 'examples/**'
- 'fledge.toml'
- 'site/**'
- 'vscode-extension/**'
- '.github/scripts/**'
- '.github/workflows/ci.yml'
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
classify:
name: Classify changed paths
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
archive-only: ${{ steps.paths.outputs.archive_only }}
full: ${{ steps.paths.outputs.full }}
site: ${{ steps.paths.outputs.site }}
vscode: ${{ steps.paths.outputs.vscode }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Classify changed paths
id: paths
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_REF: ${{ github.base_ref }}
shell: bash
run: |
set -euo pipefail
force_full=false
case "$EVENT_NAME" in
pull_request)
base="origin/$BASE_REF"
;;
push)
base="$BEFORE_SHA"
if [[ -z "$base" || "$base" =~ ^0+$ ]] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
force_full=true
fi
;;
*)
force_full=true
;;
esac
if [[ "$force_full" == "true" ]]; then
output="$(printf '' | .github/scripts/classify-ci-paths.sh "$GITHUB_WORKSPACE" true)"
else
output="$(git diff --name-only -z "$base" "$GITHUB_SHA" | .github/scripts/classify-ci-paths.sh "$GITHUB_WORKSPACE")"
fi
printf '%s\n' "$output" | tee -a "$GITHUB_OUTPUT"
- name: Test classifier
run: .github/scripts/test-classify-ci-paths.sh
test:
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89.0
- uses: Swatinem/rust-cache@v2
- run: cargo build --verbose
- run: cargo test --verbose
- run: cargo clippy -- -D warnings
fmt:
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89.0
with:
components: rustfmt
- run: cargo fmt --check
audit:
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89.0
- name: Install cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Run security audit
run: cargo audit
coverage:
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89.0
- uses: Swatinem/rust-cache@v2
- name: Install tarpaulin
run: cargo install cargo-tarpaulin --version 0.35.2 --locked --no-default-features
- name: Run coverage
run: cargo tarpaulin --skip-clean --fail-under 50
site:
needs: classify
if: needs.classify.outputs.full == 'true' || needs.classify.outputs.site == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
working-directory: site
run: bun install --frozen-lockfile
- name: Run site tests
working-directory: site
run: bun test
- name: Lint site
working-directory: site
run: bun run lint
- name: Build site
working-directory: site
run: bun run build
vscode-extension:
needs: classify
if: needs.classify.outputs.full == 'true' || needs.classify.outputs.vscode == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
working-directory: vscode-extension
run: bun install --frozen-lockfile
- name: Compile extension
working-directory: vscode-extension
run: bun run compile
- name: Package extension
working-directory: vscode-extension
run: bun run package
validate-action:
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- name: Validate action.yml
run: python3 -c "import yaml; yaml.safe_load(open('action.yml'))"
action-consumer:
name: Packaged GitHub Action consumer
needs: classify
if: needs.classify.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89.0
- uses: Swatinem/rust-cache@v2
- name: Build release archive and clean consumer
shell: bash
run: |
set -euo pipefail
cargo build --release
release_dir="${RUNNER_TEMP}/release"
consumer_dir="${RUNNER_TEMP}/consumer"
mkdir -p "$release_dir" "$consumer_dir"
cp target/release/specsync "$release_dir/specsync-linux-x86_64"
chmod +x "$release_dir/specsync-linux-x86_64"
tar czf "$release_dir/specsync-linux-x86_64.tar.gz" \
-C "$release_dir" specsync-linux-x86_64
(
cd "$release_dir"
shasum -a 256 specsync-linux-x86_64.tar.gz \
> specsync-linux-x86_64.tar.gz.sha256
)
git -C "$consumer_dir" init -b main
git -C "$consumer_dir" config user.email action-consumer@specsync.dev
git -C "$consumer_dir" config user.name "SpecSync Action Consumer"
(
cd "$consumer_dir"
"$GITHUB_WORKSPACE/target/release/specsync" init
mkdir -p src specs/greeting
printf 'pub fn hello() {}\n' > src/greeting.rs
printf '%s\n' \
'---' \
'module: greeting' \
'version: 1' \
'status: stable' \
'files:' \
' - src/greeting.rs' \
'---' \
'' \
'# Greeting' \
'' \
'## Purpose' \
'' \
'Provides a greeting.' \
'' \
'## Public API' \
'' \
'| Name | Description |' \
'|------|-------------|' \
"| \`hello\` | Return a greeting |" \
'' \
'## Invariants' \
'' \
'1. Greeting behavior remains stable.' \
'' \
'## Behavioral Examples' \
'' \
'Calling hello succeeds.' \
'' \
'## Error Cases' \
'' \
'None.' \
'' \
'## Dependencies' \
'' \
'None.' \
'' \
'## Change Log' \
'' \
'| Date | Change |' \
'|------|--------|' \
'| 2026-07-10 | Initial consumer fixture |' \
> specs/greeting/greeting.spec.md
git add .
git commit -m "Initialize clean consumer"
)
python3 -m http.server 8765 --bind 127.0.0.1 \
--directory "$release_dir" >"${RUNNER_TEMP}/specsync-release-server.log" 2>&1 &
echo "$!" > "${RUNNER_TEMP}/specsync-release-server.pid"
for attempt in 1 2 3 4 5; do
if curl -fsS http://127.0.0.1:8765/specsync-linux-x86_64.tar.gz.sha256 >/dev/null; then
exit 0
fi
echo "Release server not ready (attempt ${attempt}/5)"
sleep 1
done
exit 1
- name: Run the packaged action in the clean consumer
uses: ./
with:
version: '5.0.0'
download-base-url: 'http://127.0.0.1:8765'
root: ${{ runner.temp }}/consumer
strict: 'true'
require-coverage: '100'
lifecycle-enforce: 'true'
spec-check:
needs: classify
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
body: ${{ steps.spec-check.outputs.body }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Validate lifecycle checkout contract
run: |
python3 - <<'PY'
from pathlib import Path
import yaml
expected_ref = "$" + "{{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"
ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text())
trust = yaml.safe_load(Path(".github/workflows/trust.yml").read_text())
ci_checkout = ci["jobs"]["spec-check"]["steps"][0]
trust_checkout = trust["jobs"]["trust"]["steps"][0]
assert ci_checkout["with"] == {"ref": expected_ref, "fetch-depth": 0}
assert trust_checkout["with"] == {"ref": expected_ref, "fetch-depth": 0}
for job in ("test", "fmt", "audit", "coverage", "action-consumer"):
checkout = ci["jobs"][job]["steps"][0]
assert "ref" not in checkout.get("with", {}), job
PY
- uses: dtolnay/rust-toolchain@1.89.0
- uses: Swatinem/rust-cache@v2
- name: Build specsync
run: cargo build
- name: Run spec-check
id: spec-check
run: |
# Generate rich comment body for corvid-pet context
BODY=$(cargo run --quiet -- comment 2>/dev/null) || true
# `specsync comment` is bounded, and this second cap protects the
# action input if an older or unexpected binary is invoked here.
BODY=$(printf '%s' "$BODY" | python3 -c 'import sys; data = sys.stdin.buffer.read(); limit = 49152; sys.stdout.write(data[:limit].decode("utf-8", "ignore"))')
echo "$BODY"
write_multiline_output() {
local name="$1" value="$2" delimiter
while true; do
delimiter="SPECSYNC_$(python3 -c 'import secrets; print(secrets.token_hex(16))')"
if ! printf '%s\n' "$value" | grep -Fqx "$delimiter"; then
break
fi
done
{
printf '%s<<%s\n' "$name" "$delimiter"
printf '%s\n' "$value"
printf '%s\n' "$delimiter"
} >> "$GITHUB_OUTPUT"
}
write_multiline_output body "$BODY"
# Fail the step if check fails
cargo run -- check --strict --require-coverage 100 --force
ci-gate:
name: Required CI gate
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
needs: [classify, test, fmt, validate-action, action-consumer, spec-check, audit, coverage, site, vscode-extension]
steps:
- name: Require every selected gate
env:
RESULTS: >-
${{ join(needs.*.result, ' ') }}
run: |
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*) echo "Selected CI gate ended with: $result" >&2; exit 1 ;;
esac
done
corvid-pet:
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'pull_request' && always()
needs: [test, fmt, validate-action, action-consumer, spec-check, audit, coverage, site, vscode-extension]
steps:
- uses: actions/checkout@v5
- name: Determine combined status
id: status
env:
CHECK_TEST: "Tests (build, test, clippy)=${{ needs.test.result }}"
CHECK_FMT: "Format Check=${{ needs.fmt.result }}"
CHECK_ACTION: "Validate action.yml=${{ needs.validate-action.result }}"
CHECK_ACTION_CONSUMER: "Packaged Action Consumer=${{ needs.action-consumer.result }}"
CHECK_SPEC: "Spec Validation=${{ needs.spec-check.result }}"
CHECK_AUDIT: "Dependency Audit=${{ needs.audit.result }}"
CHECK_COVERAGE: "Code Coverage=${{ needs.coverage.result }}"
CHECK_SITE: "Docs Site=${{ needs.site.result }}"
CHECK_VSCODE: "VS Code Extension=${{ needs.vscode-extension.result }}"
REPORT_SPEC: ${{ needs.spec-check.outputs.body }}
run: |
OVERALL="success"
TABLE="| Check | Status |\n|-------|--------|\n"
DETAILS=""
while IFS= read -r line; do
VAR_NAME="${line%%=*}"
VAR_VALUE="${!VAR_NAME}"
LABEL="${VAR_VALUE%%=*}"
RESULT="${VAR_VALUE##*=}"
if [ "$RESULT" = "success" ]; then
ICON="✅ Passed"
elif [ "$RESULT" = "skipped" ]; then
ICON="⏭️ Not selected"
else
ICON="❌ ${RESULT}"
OVERALL="failure"
fi
TABLE+="| **${LABEL}** | ${ICON} |\n"
SUFFIX="${VAR_NAME#CHECK_}"
REPORT_VAR="REPORT_${SUFFIX}"
REPORT_VALUE="${!REPORT_VAR}"
if [ -n "$REPORT_VALUE" ]; then
DETAILS+="\n<details>\n<summary>📋 ${LABEL} Details</summary>\n\n${REPORT_VALUE}\n\n</details>\n"
fi
done < <(env | grep '^CHECK_' | sort)
echo "result=${OVERALL}" >> "$GITHUB_OUTPUT"
CONTEXT=$(printf '### CI Summary\n\n%b' "$TABLE")
if [ -n "$DETAILS" ]; then
CONTEXT+=$(printf '%b' "$DETAILS")
fi
write_multiline_output() {
local name="$1" value="$2" delimiter
while true; do
delimiter="SPECSYNC_$(python3 -c 'import secrets; print(secrets.token_hex(16))')"
if ! printf '%s\n' "$value" | grep -Fqx "$delimiter"; then
break
fi
done
{
printf '%s<<%s\n' "$name" "$delimiter"
printf '%s\n' "$value"
printf '%s\n' "$delimiter"
} >> "$GITHUB_OUTPUT"
}
write_multiline_output context "$CONTEXT"
- uses: CorvidLabs/corvid-pet@v1.0.0
with:
mode: pr-comment
event: auto
pet-name: Corvin
review-on-pr: "true"
context: ${{ steps.status.outputs.context }}
job-status: ${{ steps.status.outputs.result }}
attest:
name: Record attestation
runs-on: ubuntu-latest
# Depend on every gate job, not just a subset: an attestation records
# "proceed", so it must not sign off on a commit where any real check failed.
# (corvid-pet is a PR-comment mascot, not a gate, so it is intentionally out.)
needs: [ci-gate]
if: needs.ci-gate.result == 'success' && github.event_name == 'push' && github.ref == 'refs/heads/main'
# contents: write lets the step push refs/notes/attest.
permissions:
contents: write
steps:
- uses: actions/checkout@v5
# After the gates pass, record a provenance attestation on this commit and
# push it to refs/notes/attest, so spec-sync builds a trust ledger (and its
# /trust/ attest pip lights). attest ships a public Linux binary
# (checksum-verified), so no token is needed; the step is additive and
# best-effort, so a hiccup logs a warning and never fails CI.
- name: Record attestation (attest)
env:
ATTEST_VERSION: v0.5.0
run: |
base="https://github.com/CorvidLabs/attest/releases/download/${ATTEST_VERSION}"
bin="${RUNNER_TEMP}/attest"; sum="${bin}.sha256"
retry=(--retry 5 --retry-connrefused --retry-delay 1)
if curl -fsSL "${retry[@]}" "${base}/attest-linux-x86_64" -o "$bin" \
&& curl -fsSL "${retry[@]}" "${base}/attest-linux-x86_64.sha256" -o "$sum" \
&& want="$(awk '{print $1}' "$sum")" && [ -n "$want" ] \
&& printf '%s %s\n' "$want" "$bin" | sha256sum -c - ; then
chmod +x "$bin"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git fetch origin "+refs/notes/attest:refs/notes/attest" 2>/dev/null || true
if "$bin" sign --commit HEAD --reviewer agent:ci --confidence 0.9 --tests-passed \
--verdict proceed --note "Selected path-aware CI gates passed" \
&& git push origin refs/notes/attest ; then
echo "attestation recorded for $(git rev-parse --short HEAD)"
else
echo "::warning::attest sign/push failed; provenance not recorded this run"
fi
else
echo "::warning::attest download/verify failed; provenance not recorded this run"
fi