From 50c5ccdb527b9cea5385a2026971f958b4b379c6 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 10 Sep 2026 15:03:22 +0200 Subject: [PATCH 1/5] fix(review-swarm): validate candidate without self-judging --- .github/workflows/review-swarm.yml | 53 ++++++++++++++++++- .github/workflows/scripts/swarm-definition.sh | 47 ++++++++++++++++ .../scripts/swarm-definition.test.sh | 49 +++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100755 .github/workflows/scripts/swarm-definition.sh create mode 100755 .github/workflows/scripts/swarm-definition.test.sh 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" From 3563cfc0b9696e0ff8b4e28bade7e50b58d46a78 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 10 Sep 2026 19:51:42 +0200 Subject: [PATCH 2/5] ci(review-swarm): guard wrapper from candidates --- .../workflows/review-swarm-wrapper-guard.yml | 26 +++++++++++++++++++ .../workflows/scripts/swarm-wrapper-guard.sh | 12 +++++++++ 2 files changed, 38 insertions(+) create mode 100644 .github/workflows/review-swarm-wrapper-guard.yml create mode 100755 .github/workflows/scripts/swarm-wrapper-guard.sh diff --git a/.github/workflows/review-swarm-wrapper-guard.yml b/.github/workflows/review-swarm-wrapper-guard.yml new file mode 100644 index 000000000..3637ffee6 --- /dev/null +++ b/.github/workflows/review-swarm-wrapper-guard.yml @@ -0,0 +1,26 @@ +name: Review swarm wrapper guard + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + guard: + runs-on: ubuntu-latest + steps: + # pull_request_target always reads this workflow and script from the PR + # base. A candidate therefore cannot relax the guard that evaluates it. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Reject candidate-owned wrapper changes + env: + GH_TOKEN: ${{ github.token }} + REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} + run: .github/workflows/scripts/swarm-wrapper-guard.sh "$REVIEW_PR_NUMBER" diff --git a/.github/workflows/scripts/swarm-wrapper-guard.sh b/.github/workflows/scripts/swarm-wrapper-guard.sh new file mode 100755 index 000000000..174455ddc --- /dev/null +++ b/.github/workflows/scripts/swarm-wrapper-guard.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +pr_number=${1:?usage: swarm-wrapper-guard.sh PR_NUMBER} +changed_files=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" --jq '.[].filename') + +if grep -Fxq '.github/workflows/review-swarm.yml' <<<"$changed_files"; then + echo "candidate changes review-swarm.yml; wrapper enforcement is base-owned and immutable" >&2 + exit 1 +fi + +echo "REVIEW_SWARM_WRAPPER_GUARD_OK" From 8a8ece9b299860815dfbbd1e36cac7e84dd62f67 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 10 Sep 2026 19:52:39 +0200 Subject: [PATCH 3/5] refactor(review-swarm): externalize terminal diagnostics --- .github/workflows/review-swarm.yml | 25 +------------------ .../scripts/swarm-status-diagnostic.sh | 11 ++++++++ 2 files changed, 12 insertions(+), 24 deletions(-) create mode 100755 .github/workflows/scripts/swarm-status-diagnostic.sh diff --git a/.github/workflows/review-swarm.yml b/.github/workflows/review-swarm.yml index e77d5bc7d..5e43eadda 100644 --- a/.github/workflows/review-swarm.yml +++ b/.github/workflows/review-swarm.yml @@ -258,30 +258,7 @@ jobs: done echo "swarm_status=$status" >> "$GITHUB_OUTPUT" if [ "$status" != completed ]; then - # The status word alone does not say why the swarm failed, and the - # reason never reaches this log: it sits in the run payload we just - # fetched. A quota rejection reads here as a bare "failed", which - # sent one reader inferring for days before querying the run by - # hand. Print what we already have. - reason=$(jq -r '.result.error // .error // empty' <<<"${response:-}" 2>/dev/null) - if [ -n "$reason" ]; then - # The reason is not fully trusted. It can carry agent output, - # which can carry content from the PR under review. Two ways that - # bites: a line starting with `::` is parsed by Actions as a - # workflow command, and a line of three backticks would close a - # fenced block early and render the rest as markup. - # Indenting every line defeats both at once — Actions only parses - # a command at the start of a line, and an indented block is a - # Markdown code block with no fence to break. - safe_reason=$(printf '%s\n' "$reason" | sed 's/^/ /') - echo "swarm failure reason:" >&2 - printf '%s\n' "$safe_reason" >&2 - { - echo "### Swarm failure reason" - echo - printf '%s\n' "$safe_reason" - } >> "$GITHUB_STEP_SUMMARY" - fi + ../gate-files/.github/workflows/scripts/swarm-status-diagnostic.sh "${response:-}" fi exit 0 diff --git a/.github/workflows/scripts/swarm-status-diagnostic.sh b/.github/workflows/scripts/swarm-status-diagnostic.sh new file mode 100755 index 000000000..0f2f073c5 --- /dev/null +++ b/.github/workflows/scripts/swarm-status-diagnostic.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +response=${1:-} +reason=$(jq -r '.result.error // .error // empty' <<<"$response" 2>/dev/null) +if [ -n "$reason" ]; then + safe_reason=$(printf '%s\n' "$reason" | sed 's/^/ /') + echo "swarm failure reason:" >&2 + printf '%s\n' "$safe_reason" >&2 + { echo "### Swarm failure reason"; echo; printf '%s\n' "$safe_reason"; } >> "$GITHUB_STEP_SUMMARY" +fi From 794501ea050acb9b726b4a8d1d6054556cf35cee Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 10 Sep 2026 19:47:47 +0200 Subject: [PATCH 4/5] ci(review-swarm): rerun against candidate base From f27529b2f26d1ffd5c5b4f7dcf3e7aa3a371fe38 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 10 Sep 2026 19:53:12 +0200 Subject: [PATCH 5/5] fix(review-swarm): emit safe terminal diagnostics --- .../scripts/swarm-status-diagnostic.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/scripts/swarm-status-diagnostic.sh b/.github/workflows/scripts/swarm-status-diagnostic.sh index 0f2f073c5..b7343285e 100755 --- a/.github/workflows/scripts/swarm-status-diagnostic.sh +++ b/.github/workflows/scripts/swarm-status-diagnostic.sh @@ -2,10 +2,15 @@ set -euo pipefail response=${1:-} -reason=$(jq -r '.result.error // .error // empty' <<<"$response" 2>/dev/null) -if [ -n "$reason" ]; then - safe_reason=$(printf '%s\n' "$reason" | sed 's/^/ /') - echo "swarm failure reason:" >&2 - printf '%s\n' "$safe_reason" >&2 - { echo "### Swarm failure reason"; echo; printf '%s\n' "$safe_reason"; } >> "$GITHUB_STEP_SUMMARY" +failure=$(jq -c ' + def token: if type == "string" and test("^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") then . else null end; + (if type == "object" then . else {} end) as $status + | ($status.failure // {}) as $failure + | if ($failure | type) == "object" then $failure else {} end + | {phase: (.phase? // null | token), code: (.code? // null | token)} + | with_entries(select(.value != null)) +' <<<"$response" 2>/dev/null) +if [ -n "$failure" ] && [ "$failure" != '{}' ]; then + echo "swarm failure diagnostic: $failure" >&2 + echo "- Swarm failure diagnostic: \`$failure\`" >> "$GITHUB_STEP_SUMMARY" fi