Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/review-swarm-wrapper-guard.yml
Original file line number Diff line number Diff line change
@@ -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"
78 changes: 53 additions & 25 deletions .github/workflows/review-swarm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,18 @@ 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
.github/workflows/scripts/swarm-post.sh
.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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -207,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

Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/scripts/swarm-definition.sh
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions .github/workflows/scripts/swarm-definition.test.sh
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions .github/workflows/scripts/swarm-status-diagnostic.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

response=${1:-}
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
12 changes: 12 additions & 0 deletions .github/workflows/scripts/swarm-wrapper-guard.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading