|
| 1 | +name: Wheels Bot — Reviewer A (fork PRs) |
| 2 | + |
| 3 | +# Fork PRs cannot use the standard `pull_request` Reviewer A path: GitHub does |
| 4 | +# not pass `vars` OR `secrets` to `pull_request` runs from a forked repository, |
| 5 | +# so the bot's `vars.WHEELS_BOT_ENABLED == 'true'` gate fails closed (the var |
| 6 | +# reads as empty) and the App token / ANTHROPIC_API_KEY would be absent anyway. |
| 7 | +# This workflow runs the *initial* Reviewer A review for maintainer-labeled |
| 8 | +# fork PRs via `pull_request_target`, which executes in the BASE-repo context |
| 9 | +# where vars + secrets are available. |
| 10 | +# |
| 11 | +# SECURITY — pull_request_target hardening (load-bearing, do not weaken): |
| 12 | +# * We check out the BASE branch ONLY. We never check out the fork's head and |
| 13 | +# never run any fork-controlled code. The local composite action |
| 14 | +# `./.github/actions/wheels-bot-skip-check` therefore always resolves to |
| 15 | +# trusted base code. Checking out the fork ref first would let a malicious |
| 16 | +# fork swap that action's implementation and run arbitrary code with the |
| 17 | +# bot's write-capable App token + ANTHROPIC_API_KEY (the classic |
| 18 | +# "pwn-request"). |
| 19 | +# * The fork's commit OBJECTS are fetched (refs/pull/<n>/head) so the review's |
| 20 | +# read-only `git log/diff/show` work, but the working tree stays on base — |
| 21 | +# fetching objects executes nothing. |
| 22 | +# * persist-credentials:false keeps no token in .git/config. |
| 23 | +# * Gated on a maintainer-applied `bot-review` label: only users with write |
| 24 | +# access can apply labels, so a human vets the fork diff before the bot runs. |
| 25 | +# * Reviewer A's tool surface is read-only (gh + read-only git + Read/Grep/Glob). |
| 26 | +# |
| 27 | +# Downstream: when Reviewer A submits its review here, the existing |
| 28 | +# `bot-review-b.yml` (pull_request_review) and the `bot-review-a.yml` |
| 29 | +# issue_comment convergence path take over — both hardened in the same PR to |
| 30 | +# check out base, never the fork ref. |
| 31 | + |
| 32 | +on: |
| 33 | + pull_request_target: |
| 34 | + types: [labeled, synchronize] |
| 35 | + branches: [develop] |
| 36 | + |
| 37 | +permissions: |
| 38 | + # Keep the default GITHUB_TOKEN read-only (mirrors bot-review-a.yml). All |
| 39 | + # writes — posting the review, dismissing bogus reviews — go through the |
| 40 | + # App token, not this token. Minimizing the default token is extra |
| 41 | + # defense-in-depth for the pull_request_target context. |
| 42 | + contents: read |
| 43 | + |
| 44 | +concurrency: |
| 45 | + # Shared with bot-review-a.yml so a fork review and an internal review for the |
| 46 | + # same PR number can never overlap. A PR is either fork or internal, so in |
| 47 | + # practice only one of the two workflows ever matches. |
| 48 | + group: wheels-bot-review-a-${{ github.event.pull_request.number }} |
| 49 | + cancel-in-progress: false |
| 50 | + |
| 51 | +jobs: |
| 52 | + review: |
| 53 | + # Distinct from bot-review-a.yml's "Reviewer A": both workflows trigger for |
| 54 | + # a labeled fork PR (the pull_request one skips on the absent vars gate, |
| 55 | + # this one runs), so distinct check names keep the UI unambiguous. |
| 56 | + name: Reviewer A (fork) |
| 57 | + runs-on: ubuntu-latest |
| 58 | + timeout-minutes: 20 |
| 59 | + # Fork PRs only, and only once a maintainer has applied the `bot-review` |
| 60 | + # label. `synchronize` re-reviews on new pushes while the label is present; |
| 61 | + # the skip-check idempotency marker prevents duplicate reviews on a SHA |
| 62 | + # already reviewed, so re-firing on unrelated label events is a safe no-op. |
| 63 | + if: | |
| 64 | + vars.WHEELS_BOT_ENABLED == 'true' |
| 65 | + && github.event.pull_request.head.repo.fork == true |
| 66 | + && contains(github.event.pull_request.labels.*.name, 'bot-review') |
| 67 | + steps: |
| 68 | + - name: Checkout BASE branch (trusted — never the fork ref) |
| 69 | + uses: actions/checkout@v6 |
| 70 | + with: |
| 71 | + ref: ${{ github.event.pull_request.base.ref }} |
| 72 | + persist-credentials: false |
| 73 | + fetch-depth: 0 |
| 74 | + |
| 75 | + - name: Generate App token |
| 76 | + id: app-token |
| 77 | + uses: actions/create-github-app-token@v2 |
| 78 | + with: |
| 79 | + app-id: ${{ secrets.WHEELS_BOT_APP_ID }} |
| 80 | + private-key: ${{ secrets.WHEELS_BOT_PRIVATE_KEY }} |
| 81 | + |
| 82 | + - name: Resolve PR info |
| 83 | + id: pr |
| 84 | + env: |
| 85 | + # Pass event values through env (never interpolate ${{ }} straight |
| 86 | + # into the script body). Both are GitHub-generated — number is an |
| 87 | + # integer, head.sha a 40-char hex — but env + validation is the |
| 88 | + # defense-in-depth pattern. head.sha is the commit the review marker |
| 89 | + # keys off (#2848). |
| 90 | + PR_NUM: ${{ github.event.pull_request.number }} |
| 91 | + HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 92 | + run: | |
| 93 | + set -euo pipefail |
| 94 | + if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then |
| 95 | + echo "::error::PR number is not numeric: $PR_NUM" |
| 96 | + exit 1 |
| 97 | + fi |
| 98 | + if ! [[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then |
| 99 | + echo "::error::head SHA is not a hex commit id: $HEAD_SHA" |
| 100 | + exit 1 |
| 101 | + fi |
| 102 | + echo "pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT" |
| 103 | + echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" |
| 104 | +
|
| 105 | + - name: Fetch PR head commit objects (read-only; never checked out) |
| 106 | + env: |
| 107 | + PR_NUMBER: ${{ steps.pr.outputs.pr_num }} |
| 108 | + run: | |
| 109 | + set -euo pipefail |
| 110 | + # Bring the fork's commit OBJECTS into the local repo so the review's |
| 111 | + # read-only `git log/diff/show <base>..<head-sha>` resolve. The working |
| 112 | + # tree stays on the trusted base branch; no fork code is executed. |
| 113 | + git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head" |
| 114 | +
|
| 115 | + - name: Skip check |
| 116 | + id: gate |
| 117 | + uses: ./.github/actions/wheels-bot-skip-check |
| 118 | + with: |
| 119 | + target-type: pr |
| 120 | + target-number: ${{ steps.pr.outputs.pr_num }} |
| 121 | + # Initial review marker: `wheels-bot:review-a:<pr>:<sha>` (no suffix). |
| 122 | + marker-pattern: 'wheels-bot:review-a:${{ steps.pr.outputs.pr_num }}:${{ steps.pr.outputs.sha }}' |
| 123 | + github-token: ${{ steps.app-token.outputs.token }} |
| 124 | + |
| 125 | + - name: Run Reviewer A |
| 126 | + if: steps.gate.outputs.skip == 'false' |
| 127 | + uses: anthropics/claude-code-action@v1 |
| 128 | + with: |
| 129 | + allowed_bots: 'wheels-bot[bot],github-actions[bot]' |
| 130 | + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} |
| 131 | + github_token: ${{ steps.app-token.outputs.token }} |
| 132 | + prompt: | |
| 133 | + /review-pr ${{ steps.pr.outputs.pr_num }} ${{ steps.pr.outputs.sha }} |
| 134 | + claude_args: | |
| 135 | + --model claude-sonnet-4-6 |
| 136 | + --max-turns 250 |
| 137 | + --allowedTools "Bash(gh:*),Bash(git log:*),Bash(git diff:*),Bash(git show:*),Bash(git grep:*),Bash(git status),Read,Grep,Glob" |
| 138 | +
|
| 139 | + # Post-submission guard (issue #2558), mirrored from bot-review-a.yml. |
| 140 | + # Auto-dismisses any wheels-bot review on this SHA that is too short or |
| 141 | + # missing the canonical marker (e.g. a CLI-probe placeholder). Runs on |
| 142 | + # always() so it still fires if the Claude step failed mid-session. |
| 143 | + - name: Validate Reviewer A output |
| 144 | + if: always() && steps.gate.outputs.skip == 'false' |
| 145 | + env: |
| 146 | + GH_TOKEN: ${{ steps.app-token.outputs.token }} |
| 147 | + PR_NUMBER: ${{ steps.pr.outputs.pr_num }} |
| 148 | + HEAD_SHA: ${{ steps.pr.outputs.sha }} |
| 149 | + run: | |
| 150 | + set -euo pipefail |
| 151 | +
|
| 152 | + reviews=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \ |
| 153 | + | jq -c --arg sha "$HEAD_SHA" \ |
| 154 | + '[.[] | select(.user.login == "wheels-bot[bot]") | select(.commit_id == $sha) | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")]') |
| 155 | +
|
| 156 | + count=$(echo "$reviews" | jq 'length') |
| 157 | + if [[ "$count" == "0" ]]; then |
| 158 | + echo "::notice::No active wheels-bot reviews on ${HEAD_SHA} to validate" |
| 159 | + exit 0 |
| 160 | + fi |
| 161 | +
|
| 162 | + dismissed=0 |
| 163 | + while IFS= read -r row; do |
| 164 | + id=$(echo "$row" | jq -r '.id') |
| 165 | + body=$(echo "$row" | jq -r '.body') |
| 166 | + body_len=${#body} |
| 167 | +
|
| 168 | + if [[ "$body_len" -lt 200 ]] || ! grep -q 'wheels-bot:review-a' <<<"$body"; then |
| 169 | + echo "::warning::Dismissing bogus Reviewer A review id=${id} len=${body_len}" |
| 170 | + gh api -X PUT \ |
| 171 | + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${id}/dismissals" \ |
| 172 | + -f message="Auto-dismissed by Reviewer A guard: body is shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See wheels-dev/wheels#2558 for context." |
| 173 | + dismissed=$((dismissed + 1)) |
| 174 | + fi |
| 175 | + done < <(echo "$reviews" | jq -c '.[]') |
| 176 | +
|
| 177 | + if [[ "$dismissed" -gt 0 ]]; then |
| 178 | + guard_marker="wheels-bot:review-a-guard:${PR_NUMBER}:${HEAD_SHA}" |
| 179 | + existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ |
| 180 | + | jq -r --arg m "$guard_marker" '[.[] | select(.body | contains($m))] | length') |
| 181 | +
|
| 182 | + if [[ "$existing" == "0" ]]; then |
| 183 | + short_sha=${HEAD_SHA:0:7} |
| 184 | + gh pr comment "$PR_NUMBER" --body "## Wheels Bot — Reviewer A guard |
| 185 | +
|
| 186 | + Detected and dismissed ${dismissed} bogus Reviewer A review(s) on commit \`${short_sha}\`. Cause: review body shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See [wheels-dev/wheels#2558](https://github.com/wheels-dev/wheels/issues/2558) for context. |
| 187 | +
|
| 188 | + <!-- ${guard_marker} -->" |
| 189 | + else |
| 190 | + echo "::notice::Guard comment already present for ${PR_NUMBER}@${HEAD_SHA}; skipping duplicate" |
| 191 | + fi |
| 192 | + fi |
0 commit comments