Fix: complete SpecSync 5.1 lifecycle evidence hardening #838
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |