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/review-swarm.yml b/.github/workflows/review-swarm.yml index 3210e38e3..d5655263f 100644 --- a/.github/workflows/review-swarm.yml +++ b/.github/workflows/review-swarm.yml @@ -236,6 +236,12 @@ jobs: - name: Wait for cloud swarm id: wait if: always() && steps.launch.outputs.run_id != '' + # `../gate-files/` below resolves relative to this step's cwd. Every + # other step that reaches into `gate-files/` runs from `pr-head/`; + # without a matching `working-directory` here, the diagnostic call + # resolves outside the workspace and `set +e` silently swallows the + # miss (Cursor Bugbot flagged as HIGH on #285). + working-directory: pr-head run: | set +e # Ordering invariant: swarm 60m < this poll deadline 65m < job 75m. @@ -276,30 +282,7 @@ jobs: fi 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 diff --git a/.github/workflows/scripts/swarm-wrapper-guard.sh b/.github/workflows/scripts/swarm-wrapper-guard.sh new file mode 100755 index 000000000..e7d2cf231 --- /dev/null +++ b/.github/workflows/scripts/swarm-wrapper-guard.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +pr_number=${1:?usage: swarm-wrapper-guard.sh PR_NUMBER} + +# Read BOTH filename and previous_filename. A rename of +# `.github/workflows/review-swarm.yml` sets previous_filename to the guarded +# path and filename to the new location, which lets a candidate move the +# wrapper off the branch without failing a `filename`-only check +# (Cursor Bugbot flagged as MEDIUM on #285). Any touch of that path in either +# axis is a modification of the guarded wrapper. +touched_paths=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + --jq '.[] | .filename, (.previous_filename // empty)') + +if grep -Fxq '.github/workflows/review-swarm.yml' <<<"$touched_paths"; then + echo "candidate changes review-swarm.yml (add, edit, or rename); wrapper enforcement is base-owned and immutable" >&2 + exit 1 +fi + +echo "REVIEW_SWARM_WRAPPER_GUARD_OK"