feat(release): bind publication to immutable RC (#492) #1063
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 | |
| actions: 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' | |
| - '.github/workflows/pages.yml' | |
| - '.github/workflows/finalize-change.yml' | |
| - '.github/workflows/lifecycle-policy-guard.yml' | |
| - '.github/workflows/post-merge-archive.yml' | |
| - '.github/workflows/release.yml' | |
| - '.github/workflows/trust.yml' | |
| - 'CHANGELOG.md' | |
| - 'README.md' | |
| - 'SECURITY.md' | |
| 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' | |
| - '.github/workflows/pages.yml' | |
| - '.github/workflows/finalize-change.yml' | |
| - '.github/workflows/lifecycle-policy-guard.yml' | |
| - '.github/workflows/post-merge-archive.yml' | |
| - '.github/workflows/release.yml' | |
| - '.github/workflows/trust.yml' | |
| - 'CHANGELOG.md' | |
| - 'README.md' | |
| - 'SECURITY.md' | |
| 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 }} | |
| legacy_archive_only: ${{ steps.paths.outputs.legacy_archive_only }} | |
| archive_change_id: ${{ steps.paths.outputs.archive_change_id }} | |
| archive_dir: ${{ steps.paths.outputs.archive_dir }} | |
| archive_workflow_version: ${{ steps.paths.outputs.archive_workflow_version }} | |
| review_only: ${{ steps.paths.outputs.review_only }} | |
| review_change_id: ${{ steps.paths.outputs.review_change_id }} | |
| review_required: ${{ steps.paths.outputs.review_required }} | |
| review_required_change_id: ${{ steps.paths.outputs.review_required_change_id }} | |
| full: ${{ steps.paths.outputs.full }} | |
| site: ${{ steps.paths.outputs.site }} | |
| vscode: ${{ steps.paths.outputs.vscode }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| 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 }} | |
| HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| force_full=false | |
| case "$EVENT_NAME" in | |
| pull_request) | |
| base="origin/$BASE_REF" | |
| parent_line="$(git rev-list --parents -n 1 "$HEAD_SHA")" | |
| if [[ "$(wc -w <<<"$parent_line" | tr -d ' ')" == "2" ]]; then | |
| parent="$(git rev-parse "$HEAD_SHA^")" | |
| child_output="$( | |
| git diff --name-status -z -M "$parent" "$HEAD_SHA" \ | |
| | .github/scripts/classify-ci-paths.sh \ | |
| "$GITHUB_WORKSPACE" false name-status "$parent" | |
| )" | |
| if grep -Eq '^(archive_only|legacy_archive_only|review_only)=true$' <<<"$child_output"; then | |
| output="$child_output" | |
| fi | |
| fi | |
| ;; | |
| 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 [[ -n "${output:-}" ]]; then | |
| : | |
| elif [[ "$force_full" == "true" ]]; then | |
| output="$(printf '' | .github/scripts/classify-ci-paths.sh "$GITHUB_WORKSPACE" true)" | |
| else | |
| output="$(git diff --name-only -z "$base" "$HEAD_SHA" | .github/scripts/classify-ci-paths.sh "$GITHUB_WORKSPACE")" | |
| fi | |
| printf '%s\n' "$output" | tee -a "$GITHUB_OUTPUT" | |
| - name: Test lifecycle CI routing | |
| run: | | |
| .github/scripts/test-classify-ci-paths.sh | |
| .github/scripts/test-lifecycle-workflows.sh | |
| python3 .github/scripts/test-validate-release-candidate.py | |
| archive-integrity: | |
| needs: classify | |
| if: needs.classify.outputs.archive_only == 'true' | |
| uses: ./.github/workflows/finalize-change.yml | |
| with: | |
| head_sha: ${{ github.event.pull_request.head.sha }} | |
| change_id: ${{ needs.classify.outputs.archive_change_id }} | |
| archive_dir: ${{ needs.classify.outputs.archive_dir }} | |
| permissions: | |
| contents: read | |
| actions: read | |
| checks: read | |
| test: | |
| needs: classify | |
| if: needs.classify.outputs.full == 'true' | |
| # Ordinary development has one authoritative integration platform. The | |
| # immutable release-candidate workflow qualifies the exact candidate SHA on | |
| # Ubuntu, macOS, and Windows before any final tag can be created. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| 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 | |
| # Ptrace can abort this special-file/process-spawning suite with | |
| # munmap/ECHILD failures. LLVM instrumentation preserves the full test | |
| # set and 50% gate without relying on ptrace trap delivery. | |
| run: >- | |
| cargo tarpaulin | |
| --engine llvm | |
| --follow-exec | |
| --post-test-delay 10 | |
| --skip-clean | |
| --fail-under 50 | |
| -- | |
| --test-threads 1 | |
| 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 | |
| with: | |
| bun-version: '1.3.14' | |
| - 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 | |
| with: | |
| bun-version: '1.3.14' | |
| - 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.archive_only != 'true' && needs.classify.outputs.review_only != 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.3' | |
| - name: Validate pinned workflow runtimes | |
| run: python3 .github/scripts/validate-workflow-runtime-pins.py | |
| - name: Validate release version consistency | |
| run: python3 .github/scripts/validate-release-version.py | |
| 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: '6.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 | |
| if: needs.classify.outputs.archive_only != 'true' && needs.classify.outputs.review_only != 'true' | |
| 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 | |
| - 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 | |
| scoped-review-reuse: | |
| name: SpecSync scoped review | |
| needs: classify | |
| if: needs.classify.outputs.review_only == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| actions: read | |
| checks: read | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| fetch-depth: 0 | |
| - name: Reuse the implementation parent's scoped review | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPOSITORY: ${{ github.repository }} | |
| SERVER_URL: ${{ github.server_url }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| CHANGE_ID: ${{ needs.classify.outputs.review_change_id }} | |
| GIT_ROOT: ${{ github.workspace }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| parent_line="$(git rev-list --parents -n 1 "$HEAD_SHA")" | |
| if [[ "$(wc -w <<<"$parent_line" | tr -d ' ')" != "2" ]]; then | |
| echo "Review metadata must be a single-parent child commit." >&2 | |
| exit 1 | |
| fi | |
| parent_sha="$(git rev-parse "$HEAD_SHA^")" | |
| output="$( | |
| git diff --name-status -z "$parent_sha" "$HEAD_SHA" \ | |
| | .github/scripts/classify-ci-paths.sh \ | |
| "$GITHUB_WORKSPACE" false name-status "$parent_sha" | |
| )" | |
| if ! grep -Fxq 'review_only=true' <<<"$output" \ | |
| || ! grep -Fxq "review_change_id=${CHANGE_ID}" <<<"$output"; then | |
| echo "The child commit is not one exact scoped-review record." >&2 | |
| exit 1 | |
| fi | |
| active=".specsync/changes/${CHANGE_ID}" | |
| export REVIEW_JSON | |
| export REVIEW_ATTEMPTS_JSON | |
| export PARENT_REVIEW_ATTEMPTS_JSON | |
| export STATE_JSON | |
| export APPROVALS_JSON | |
| export PARENT_VERIFICATION_JSON | |
| export IMPLEMENTATION_SHA | |
| export PARENT_SHA="$parent_sha" | |
| REVIEW_JSON="$(git show "${HEAD_SHA}:${active}/review.json")" | |
| REVIEW_ATTEMPTS_JSON="$( | |
| git show "${HEAD_SHA}:${active}/review-attempts.json" | |
| )" | |
| PARENT_REVIEW_ATTEMPTS_JSON="" | |
| if git cat-file -e "${parent_sha}:${active}/review-attempts.json" 2>/dev/null; then | |
| PARENT_REVIEW_ATTEMPTS_JSON="$( | |
| git show "${parent_sha}:${active}/review-attempts.json" | |
| )" | |
| fi | |
| STATE_JSON="$(git show "${HEAD_SHA}:${active}/state.json")" | |
| APPROVALS_JSON="$(git show "${HEAD_SHA}:${active}/approvals.json")" | |
| PARENT_VERIFICATION_JSON="$( | |
| git show "${parent_sha}:${active}/verification.json" | |
| )" | |
| IMPLEMENTATION_SHA="$( | |
| jq -er '.implementation_commit' <<<"$REVIEW_JSON" | |
| )" | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import re | |
| import selectors | |
| import subprocess | |
| import time | |
| review = json.loads(os.environ["REVIEW_JSON"]) | |
| review_attempts = json.loads(os.environ["REVIEW_ATTEMPTS_JSON"]) | |
| parent_review_attempts = ( | |
| json.loads(os.environ["PARENT_REVIEW_ATTEMPTS_JSON"]) | |
| if os.environ["PARENT_REVIEW_ATTEMPTS_JSON"] | |
| else {"schema_version": 1, "reviews": []} | |
| ) | |
| state = json.loads(os.environ["STATE_JSON"]) | |
| approvals = json.loads(os.environ["APPROVALS_JSON"]) | |
| verification = json.loads(os.environ["PARENT_VERIFICATION_JSON"]) | |
| change_id = os.environ["CHANGE_ID"] | |
| parent = os.environ["PARENT_SHA"] | |
| implementation = os.environ["IMPLEMENTATION_SHA"] | |
| with open( | |
| ".github/scripts/lifecycle-validation-limits.json", | |
| encoding="utf-8", | |
| ) as handle: | |
| limits = json.load(handle) | |
| output_limit = int(limits["git_max_output_bytes"]) | |
| timeout = int(limits["git_timeout_seconds"]) | |
| descendant_limit = int(limits["scoped_review_max_descendants"]) | |
| parent_limit = int(limits["scoped_review_max_parents"]) | |
| def git_bytes(*args): | |
| process = subprocess.Popen( | |
| ["git", *args], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| ) | |
| if process.stdout is None or process.stderr is None: | |
| process.kill() | |
| raise SystemExit("failed to capture bounded review-history query") | |
| selector = selectors.DefaultSelector() | |
| selector.register(process.stdout, selectors.EVENT_READ, "stdout") | |
| selector.register(process.stderr, selectors.EVENT_READ, "stderr") | |
| stdout = bytearray() | |
| stderr = bytearray() | |
| total = 0 | |
| deadline = time.monotonic() + timeout | |
| try: | |
| while selector.get_map(): | |
| remaining = deadline - time.monotonic() | |
| if remaining <= 0: | |
| process.kill() | |
| process.wait() | |
| raise SystemExit("review-history query timed out") | |
| for key, _ in selector.select(timeout=remaining): | |
| chunk = os.read(key.fileobj.fileno(), 64 * 1024) | |
| if not chunk: | |
| selector.unregister(key.fileobj) | |
| continue | |
| total += len(chunk) | |
| if total > output_limit: | |
| process.kill() | |
| process.wait() | |
| raise SystemExit("review-history query exceeded output limit") | |
| if key.data == "stdout": | |
| stdout.extend(chunk) | |
| elif len(stderr) < 4096: | |
| stderr.extend(chunk[:4096 - len(stderr)]) | |
| returncode = process.wait(timeout=5) | |
| finally: | |
| selector.close() | |
| if process.poll() is None: | |
| process.kill() | |
| process.wait() | |
| if returncode != 0: | |
| detail = bytes(stderr).decode(errors="replace").strip() | |
| raise SystemExit( | |
| f"git {' '.join(args)} failed ({returncode}): {detail}" | |
| ) | |
| return bytes(stdout) | |
| def git_text(*args): | |
| return git_bytes(*args).decode().strip() | |
| def git_status(*args): | |
| try: | |
| return subprocess.run( | |
| ["git", *args], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| check=False, | |
| timeout=timeout, | |
| ).returncode | |
| except subprocess.TimeoutExpired as error: | |
| raise SystemExit("review-history status query timed out") from error | |
| if state.get("id") != change_id or state.get("state") != "verifying": | |
| raise SystemExit("scoped-review child does not retain the verifying change") | |
| if state.get("workflow_version") != 2: | |
| raise SystemExit("scoped-review child is not a workflow_version=2 change") | |
| if review.get("schema_version") != 2 or review.get("change_id") != change_id: | |
| raise SystemExit("scoped-review evidence has the wrong schema or change") | |
| if ( | |
| review_attempts.get("schema_version") != 1 | |
| or not review_attempts.get("reviews") | |
| or review_attempts["reviews"][-1] != review | |
| ): | |
| raise SystemExit( | |
| "scoped-review projection does not match append-only attempt history" | |
| ) | |
| parent_reviews = parent_review_attempts.get("reviews") | |
| current_reviews = review_attempts["reviews"] | |
| if ( | |
| parent_review_attempts.get("schema_version") != 1 | |
| or not isinstance(parent_reviews, list) | |
| or len(current_reviews) != len(parent_reviews) + 1 | |
| or current_reviews[:-1] != parent_reviews | |
| ): | |
| raise SystemExit( | |
| "scoped-review child is not a strict append-only history extension" | |
| ) | |
| def valid_reviewer_claim(value): | |
| return ( | |
| isinstance(value, str) | |
| and 1 <= len(value) <= 128 | |
| and value.isascii() | |
| and re.fullmatch(r"[A-Za-z0-9 ._:@/-]+", value) is not None | |
| ) | |
| for attempt in current_reviews: | |
| if ( | |
| attempt.get("schema_version") != 2 | |
| or attempt.get("change_id") != change_id | |
| or not valid_reviewer_claim(attempt.get("reviewer")) | |
| or attempt.get("provenance") != { | |
| "schema_version": 1, | |
| "provider": "github_actions_check", | |
| "required_check": "SpecSync scoped review", | |
| } | |
| ): | |
| raise SystemExit("scoped-review attempt history contains invalid evidence") | |
| if review.get("provenance") != { | |
| "schema_version": 1, | |
| "provider": "github_actions_check", | |
| "required_check": "SpecSync scoped review", | |
| }: | |
| raise SystemExit("scoped-review evidence has invalid required-check provenance") | |
| if not valid_reviewer_claim(review.get("reviewer")): | |
| raise SystemExit("scoped-review evidence has an invalid stable reviewer claim") | |
| if review.get("verdict") != "pass": | |
| raise SystemExit("scoped-review evidence does not record a passing verdict") | |
| definition_approvals = [ | |
| approval for approval in approvals.get("approvals", []) | |
| if approval.get("gate") == "definition" | |
| ] | |
| if not definition_approvals: | |
| raise SystemExit("scope approval evidence is missing") | |
| scope_approver = str(definition_approvals[-1].get("actor") or "").strip() | |
| if review["reviewer"].strip().casefold() == scope_approver.casefold(): | |
| raise SystemExit( | |
| "scoped reviewer is not independent from the scope approver" | |
| ) | |
| if ( | |
| review.get("implementation_commit") != implementation | |
| or re.fullmatch(r"[0-9a-f]{40}", implementation) is None | |
| or git_text("rev-parse", "--verify", f"{implementation}^{{commit}}") | |
| != implementation | |
| or git_status("merge-base", "--is-ancestor", implementation, parent) != 0 | |
| ): | |
| raise SystemExit( | |
| "scoped-review evidence is not bound to an implementation ancestor" | |
| ) | |
| descendants = git_text( | |
| "rev-list", | |
| "--reverse", | |
| f"--max-count={descendant_limit + 1}", | |
| f"{implementation}..{parent}", | |
| ).splitlines() | |
| if len(descendants) > descendant_limit: | |
| raise SystemExit("scoped-review ancestry exceeds the descendant limit") | |
| allowed = { | |
| f"{active}/review.json", | |
| f"{active}/review-attempts.json", | |
| f"{active}/state.json", | |
| f"{active}/verification.json", | |
| f"{active}/verification-attempts.json", | |
| } | |
| for descendant in descendants: | |
| fields = git_text( | |
| "rev-list", | |
| "--parents", | |
| "-n", | |
| "1", | |
| descendant, | |
| ).split() | |
| if ( | |
| not fields | |
| or fields[0] != descendant | |
| or not 1 <= len(fields) - 1 <= parent_limit | |
| ): | |
| raise SystemExit( | |
| f"scoped-review descendant {descendant} has invalid parents" | |
| ) | |
| for ancestor in fields[1:]: | |
| paths = git_bytes( | |
| "diff-tree", | |
| "--no-commit-id", | |
| "--name-only", | |
| "--no-renames", | |
| "-z", | |
| "-r", | |
| ancestor, | |
| descendant, | |
| ).split(b"\0") | |
| changed = { | |
| path.decode() | |
| for path in paths | |
| if path | |
| } | |
| disallowed = sorted(changed - allowed) | |
| if disallowed: | |
| raise SystemExit( | |
| "implementation changed after scoped review: " | |
| + ", ".join(disallowed) | |
| ) | |
| for field in ("contract_digest", "execution_digest", "workspace_digest"): | |
| if not re.fullmatch(r"[0-9a-f]{64}", str(review.get(field) or "")): | |
| raise SystemExit(f"scoped-review evidence has an invalid {field}") | |
| if review.get(field) != verification.get(field): | |
| raise SystemExit( | |
| f"scoped-review {field} does not match parent verification.json" | |
| ) | |
| if verification.get("passed") is not True: | |
| raise SystemExit("implementation parent verification.json is not passing") | |
| PY | |
| HEAD_SHA="$IMPLEMENTATION_SHA" \ | |
| .github/scripts/verify-trusted-policy-check.py | |
| checks_file="${RUNNER_TEMP}/specsync-review-implementation-checks.json" | |
| gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "repos/${REPOSITORY}/commits/${IMPLEMENTATION_SHA}/check-runs?per_page=100" \ | |
| > "$checks_file" | |
| export CHECKS_FILE="$checks_file" | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| implementation = os.environ["IMPLEMENTATION_SHA"] | |
| repository = os.environ["REPOSITORY"] | |
| server_url = os.environ["SERVER_URL"].rstrip("/") | |
| pull_request = int(os.environ["PR_NUMBER"]) | |
| with open(os.environ["CHECKS_FILE"], encoding="utf-8") as handle: | |
| checks = json.load(handle).get("check_runs", []) | |
| def api(endpoint: str) -> dict: | |
| output = subprocess.check_output( | |
| [ | |
| "gh", | |
| "api", | |
| "-H", | |
| "Accept: application/vnd.github+json", | |
| "-H", | |
| "X-GitHub-Api-Version: 2022-11-28", | |
| endpoint, | |
| ], | |
| text=True, | |
| ) | |
| return json.loads(output) | |
| github_actions_app = api("apps/github-actions") | |
| if ( | |
| github_actions_app.get("slug") != "github-actions" | |
| or github_actions_app.get("name") != "GitHub Actions" | |
| or (github_actions_app.get("owner") or {}).get("login") != "github" | |
| ): | |
| raise SystemExit("could not resolve the official GitHub Actions app") | |
| def proven_check( | |
| name: str, | |
| workflow_path: str, | |
| require_run_success: bool, | |
| ) -> int: | |
| errors = [] | |
| candidates = sorted( | |
| (check for check in checks if check.get("name") == name), | |
| key=lambda check: int(check.get("id", 0)), | |
| reverse=True, | |
| ) | |
| for check in candidates: | |
| try: | |
| if check.get("head_sha") != implementation: | |
| raise ValueError("wrong head SHA") | |
| if ( | |
| check.get("status") != "completed" | |
| or check.get("conclusion") != "success" | |
| ): | |
| raise ValueError("check is not successful") | |
| app = check.get("app") or {} | |
| if ( | |
| app.get("id") != github_actions_app.get("id") | |
| or app.get("slug") != github_actions_app.get("slug") | |
| ): | |
| raise ValueError("check is not from GitHub Actions") | |
| details_url = str(check.get("details_url") or "") | |
| match = re.fullmatch( | |
| ( | |
| rf"{re.escape(server_url)}/{re.escape(repository)}" | |
| r"/actions/runs/([0-9]+)/job/[0-9]+" | |
| ), | |
| details_url, | |
| ) | |
| if match is None: | |
| raise ValueError("invalid GitHub Actions details URL") | |
| run_id = int(match.group(1)) | |
| workflow_run = api( | |
| f"repos/{repository}/actions/runs/{run_id}" | |
| ) | |
| if workflow_run.get("id") != run_id: | |
| raise ValueError("wrong workflow run ID") | |
| if workflow_run.get("head_sha") != implementation: | |
| raise ValueError("workflow run has the wrong head SHA") | |
| if workflow_run.get("event") != "pull_request": | |
| raise ValueError("workflow run is not for a pull request") | |
| if workflow_run.get("status") != "completed": | |
| raise ValueError("workflow run is not completed") | |
| if require_run_success and workflow_run.get("conclusion") != "success": | |
| raise ValueError("workflow run is not successful") | |
| if ( | |
| str(workflow_run.get("path") or "").split("@", 1)[0] | |
| != workflow_path | |
| ): | |
| raise ValueError("workflow run has the wrong path") | |
| if (workflow_run.get("repository") or {}).get( | |
| "full_name" | |
| ) != repository: | |
| raise ValueError("workflow run belongs to another repository") | |
| if not any( | |
| item.get("number") == pull_request | |
| for item in workflow_run.get("pull_requests") or [] | |
| ): | |
| raise ValueError("workflow run is not bound to this PR") | |
| return run_id | |
| except ( | |
| json.JSONDecodeError, | |
| subprocess.CalledProcessError, | |
| TypeError, | |
| ValueError, | |
| ) as error: | |
| errors.append(f"check {check.get('id')}: {error}") | |
| detail = "; ".join(errors) if errors else "no matching checks" | |
| raise SystemExit(f"{name} has no proven implementation result: {detail}") | |
| implementation_run = proven_check( | |
| "SpecSync implementation ready", | |
| ".github/workflows/ci.yml", | |
| False, | |
| ) | |
| proven_check("trust", ".github/workflows/trust.yml", True) | |
| print( | |
| "Reused proven implementation and trust checks from reviewed " | |
| f"ancestor {implementation} for PR #{pull_request}; this job is " | |
| "the new successful scoped-review check." | |
| ) | |
| PY | |
| implementation-gate: | |
| name: SpecSync implementation ready | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| if: ${{ always() && needs.classify.outputs.archive_only != 'true' }} | |
| needs: [classify, test, fmt, validate-action, action-consumer, spec-check, audit, coverage, site, vscode-extension, scoped-review-reuse, corvid-pet] | |
| 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 | |
| ci-gate: | |
| name: Required CI gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| if: always() | |
| needs: [classify, archive-integrity, implementation-gate] | |
| steps: | |
| - name: Require finalization before GitHub merge | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| ARCHIVE_ONLY: ${{ needs.classify.outputs.archive_only }} | |
| LEGACY_ARCHIVE_ONLY: ${{ needs.classify.outputs.legacy_archive_only }} | |
| ARCHIVE_RESULT: ${{ needs.archive-integrity.result }} | |
| IMPLEMENTATION_RESULT: ${{ needs.implementation-gate.result }} | |
| run: | | |
| if [[ "$ARCHIVE_ONLY" == "true" ]]; then | |
| if [[ "$ARCHIVE_RESULT" != "success" ]]; then | |
| echo "Archive integrity gate ended with: $ARCHIVE_RESULT" >&2 | |
| exit 1 | |
| fi | |
| exit 0 | |
| fi | |
| if [[ "$IMPLEMENTATION_RESULT" != "success" ]]; then | |
| echo "Implementation gate ended with: $IMPLEMENTATION_RESULT" >&2 | |
| exit 1 | |
| fi | |
| if [[ "$LEGACY_ARCHIVE_ONLY" == "true" ]]; then | |
| echo "Legacy workflow-v1 archive passed the historical full-validation path." | |
| exit 0 | |
| fi | |
| if [[ "$EVENT_NAME" == "pull_request" ]]; then | |
| echo "Implementation is green. Run specsync change finalize, commit the exact archive move, and push it before GitHub merge." >&2 | |
| exit 1 | |
| fi | |
| corvid-pet: | |
| name: SpecSync scoped review | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # This is the one independent, implementation-scoped review. A proven | |
| # archive-only child reuses the green result on its parent and never runs | |
| # the reviewer a second time. Forks run the same read-only review without | |
| # attempting a comment or review write. | |
| if: ${{ always() && github.event_name == 'pull_request' && needs.classify.outputs.review_required == 'true' }} | |
| needs: [classify, test, fmt, validate-action, action-consumer, spec-check, audit, coverage, site, vscode-extension] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| 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 | |
| comment-on-pr: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'true' || 'false' }} | |
| review-on-pr: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'true' || 'false' }} | |
| 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 same-repository PR 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 |