diff --git a/.github/workflows/review-swarm.yml b/.github/workflows/review-swarm.yml index 02c3c12ef..e77d5bc7d 100644 --- a/.github/workflows/review-swarm.yml +++ b/.github/workflows/review-swarm.yml @@ -39,7 +39,9 @@ jobs: - name: Check out immutable gate from main uses: actions/checkout@v4 with: - ref: main + # The base SHA is the immutable definition this PR is judged by. A + # moving `main` ref could change the judge while this run is live. + ref: ${{ github.event.pull_request.base.sha }} path: gate-files sparse-checkout: | workflows/review-swarm.yaml @@ -47,6 +49,8 @@ jobs: .github/workflows/scripts/swarm-prepare.sh .github/workflows/scripts/swarm-verdict.sh .github/workflows/scripts/swarm-gate.test.sh + .github/workflows/scripts/swarm-definition.sh + .github/workflows/scripts/swarm-definition.test.sh # The gate decides whether code merges, so before it judges anything it # proves it can still say no. `swarm-verdict.sh` is a handful of lines of @@ -70,6 +74,8 @@ jobs: env: REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} run: | + ruby --version + ruby -ryaml -e 'abort "Psych YAML parser unavailable" unless defined?(Psych)' test_script=gate-files/.github/workflows/scripts/swarm-gate.test.sh if [ ! -f "$test_script" ]; then # Only the introducing PR may bootstrap before its test is on main. @@ -82,6 +88,51 @@ jobs: exit 1 fi bash "$test_script" + definition_test=gate-files/.github/workflows/scripts/swarm-definition.test.sh + if [ ! -f "$definition_test" ]; then + # The introducing PR cannot run a helper that is not on its base + # yet. Once this PR lands, absence is a deletion or checkout bug + # and must fail closed like the verdict self-test above. + if [ "$REVIEW_PR_NUMBER" = 265 ]; then + echo "::notice::PR #265 bootstrap: candidate validator is not on main yet." + else + echo "::error::main-owned candidate validator is missing" >&2 + exit 1 + fi + else + bash "$definition_test" + fi + + # A PR that changes the relayflow definition cannot be judged by that + # definition without violating the immutable-gate rule. Parse and check + # the candidate's load-bearing policy against the trusted base, but keep + # the actual review run below on the base definition. This is a candidate + # contract check, not a candidate verdict. + - name: Validate candidate review definition + env: + REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} + REVIEW_BASE_SHA: ${{ github.event.pull_request.base.sha }} + REVIEW_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + working-directory: pr-head + run: | + # Compare the PR patch (merge-base...head), not the two trees. A + # branch can be stale on this file without the PR changing it. + if git diff --quiet "$REVIEW_BASE_SHA...$REVIEW_HEAD_SHA" -- workflows/review-swarm.yaml; then + echo "Candidate review definition unchanged; trusted base definition is the effective file." + exit 0 + fi + validator=../gate-files/.github/workflows/scripts/swarm-definition.sh + if [ ! -f "$validator" ]; then + if [ "$REVIEW_PR_NUMBER" = 265 ]; then + echo "::notice::PR #265 bootstrap: candidate validator is not on main yet." + else + echo "::error::main-owned candidate validator is missing" >&2 + exit 1 + fi + else + "$validator" workflows/review-swarm.yaml \ + ../gate-files/workflows/review-swarm.yaml + fi # Fail here, in seconds, rather than in `Launch cloud swarm` ten minutes # later. WorkflowApiKeyClient.fromEnv requires CLOUD_API_URL and diff --git a/.github/workflows/scripts/swarm-definition.sh b/.github/workflows/scripts/swarm-definition.sh new file mode 100755 index 000000000..d4bc4a505 --- /dev/null +++ b/.github/workflows/scripts/swarm-definition.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +candidate=${1:?usage: swarm-definition.sh CANDIDATE TRUSTED} +trusted=${2:?usage: swarm-definition.sh CANDIDATE TRUSTED} + +test -s "$candidate" || { echo "candidate workflow is missing: $candidate" >&2; exit 1; } +test -s "$trusted" || { echo "trusted workflow is missing: $trusted" >&2; exit 1; } + +# The candidate is parsed for structural and policy invariants only. It is never +# used as the authority for the review result: the caller must still launch the +# trusted definition from the base commit. Ruby's stdlib YAML parser is present +# on the GitHub runner, so this check adds no floating dependency. +ruby -ryaml - "$candidate" "$trusted" <<'RUBY' +candidate_path, trusted_path = ARGV + +def load_workflow(path, label) + abort "#{label} must not be a symbolic link" if File.lstat(path).symlink? + value = YAML.safe_load(File.read(path), aliases: false) + abort "#{label} is not a YAML mapping" unless value.is_a?(Hash) + value +rescue Psych::Exception => error + abort "#{label} is invalid YAML: #{error.message.lines.first.strip}" +end + +candidate = load_workflow(candidate_path, "candidate workflow") +trusted = load_workflow(trusted_path, "trusted workflow") + +swarm = candidate.fetch("swarm") +abort "candidate swarm must be a mapping" unless swarm.is_a?(Hash) +error_handling = candidate.fetch("errorHandling") +abort "candidate errorHandling must be a mapping" unless error_handling.is_a?(Hash) + +strategy = error_handling.fetch("strategy") +abort "candidate errorHandling.strategy must be retry (got #{strategy.inspect})" unless strategy == "retry" + +delay = error_handling.fetch("retryDelayMs") +abort "candidate retryDelayMs must be a positive integer" unless delay.is_a?(Integer) && delay.positive? +abort "candidate retryDelayMs must honor the platform's 60s backoff (got #{delay})" unless delay >= 60_000 + +trusted_timeout = trusted.fetch("swarm").fetch("timeoutMs") +candidate_timeout = swarm.fetch("timeoutMs") +abort "candidate timeoutMs must remain #{trusted_timeout} (got #{candidate_timeout.inspect})" unless candidate_timeout == trusted_timeout + +puts "CANDIDATE_DEFINITION_OK" +puts "retry strategy=#{strategy} retryDelayMs=#{delay} timeoutMs=#{candidate_timeout}" +RUBY diff --git a/.github/workflows/scripts/swarm-definition.test.sh b/.github/workflows/scripts/swarm-definition.test.sh new file mode 100755 index 000000000..ca3f4f2e0 --- /dev/null +++ b/.github/workflows/scripts/swarm-definition.test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +validator="$script_dir/swarm-definition.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +cat > "$work/trusted.yaml" <<'YAML' +swarm: + timeoutMs: 3600000 +YAML + +cat > "$work/candidate.yaml" <<'YAML' +version: '1.0' +swarm: + timeoutMs: 3600000 +errorHandling: + strategy: retry + retryDelayMs: 60000 +YAML + +expect_reject() { + local label=$1 + shift + if "$@" >"$work/out" 2>&1; then + echo "FAIL: $label unexpectedly passed" >&2 + cat "$work/out" >&2 + exit 1 + fi + echo "ok: $label" +} + +"$validator" "$work/candidate.yaml" "$work/trusted.yaml" | grep -q '^CANDIDATE_DEFINITION_OK$' +echo "ok: valid candidate passes" + +sed 's/retryDelayMs: 60000/retryDelayMs: 1000/' "$work/candidate.yaml" > "$work/short-delay.yaml" +expect_reject "short retry delay" "$validator" "$work/short-delay.yaml" "$work/trusted.yaml" + +sed 's/timeoutMs: 3600000/timeoutMs: 1800000/' "$work/candidate.yaml" > "$work/short-timeout.yaml" +expect_reject "changed timeout" "$validator" "$work/short-timeout.yaml" "$work/trusted.yaml" + +printf 'not: [valid\n' > "$work/broken.yaml" +expect_reject "invalid YAML" "$validator" "$work/broken.yaml" "$work/trusted.yaml" + +ln -s "$work/candidate.yaml" "$work/candidate-link.yaml" +expect_reject "symbolic-link candidate" "$validator" "$work/candidate-link.yaml" "$work/trusted.yaml" + +echo "swarm-definition: all tests passed"