Skip to content

feat(release): bind publication to immutable RC #1060

feat(release): bind publication to immutable RC

feat(release): bind publication to immutable RC #1060

Workflow file for this run

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