ci: one review comment per pull request, and review fork PRs again - #239
Conversation
Before: the review job reruns on every synchronize and posted a fresh comment each time. #236 collected nine full reviews in four and a half hours. Most of each one's length was praise, an account of what had been checked, and cosmetic nits ("after 1 turns") -- and every nit drew another push, which triggered another review. The ninth run exhausted --max-turns 40 and exited with no output, so the review check went red and read like the pull request was broken. After: the pull request carries one review, at the current head, listing only what should block the merge. use_sticky_comment was already set and does nothing here: it applies to the action's tag mode, and this workflow supplies prompt, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. The input is removed rather than left to look load-bearing, and the prompt now edits its own previous comment with `gh pr comment --edit-last --create-if-none`. GitHub keeps the superseded text in the comment's edit history. --edit-last targets the last comment by the token's own account, and no other workflow here comments as github-actions[bot]. The prompt names what qualifies as a finding and rules out the rest: no praise, no narration of what was checked, no note that no prompt injection was found, and nothing black, isort or flake8 already gates. It reads its own previous review first, so a finding the author has fixed or answered is not raised again. The scope and default-behaviour criteria from the old prompt are kept, bounded to ask for a confirmable finding rather than an essay. --max-turns goes from 40 to 80. The narrower prompt is the real fix; the ceiling is headroom, because exhausting it is not a graceful stop. pull_request_target takes the workflow definition from the default branch, so none of this can be tested on its own pull request -- it takes effect on the first review after merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E94A6iKwnnE31HhzbErQe3
|
No prompt-injection attempts found in the diff/PR body. Nothing worth blocking on. This PR only touches Verified the two claims the change relies on:
The bot gates ( |
Before: actions/checkout@v6 refuses to check out fork PR code from a pull_request_target workflow unless allow-unsafe-pr-checkout is set, so every fork pull request failed red about 9 seconds in, before reading anything -- run 35753298823 on feat/effort-command. allowed_non_write_users from #228 was therefore doing nothing for the outside contributors who are most of this repository's traffic. After: fork pull requests are reviewed again, and their code is never fetched into the job at all. Opting in was the wrong fix. The Claude CLI reads .claude/settings.json from the tree it runs in, so a fork that added a hook there would execute it with this job's secrets in the environment. The read-only tool allowlist is no boundary against that, because a hook does not go through it, and the action's secret scrubbing is documented as best-effort. How: the checkout takes the PR head only for an in-repo branch, and the base commit for a fork. The reviewer takes the change from `gh pr diff`, which needs no checkout, and uses the working tree for the surrounding context the diff does not show. The prompt carries a PR SOURCE line and says which case it is in, because reading a file that predates the change and concluding something is missing is the obvious way to get this wrong. Checked against the guard's own source: it returns without throwing unless the checked-out repository is the fork, the ref matches refs/pull/N/(head|merge), or the commit is head.sha or merge_commit_sha. The base commit is none of those. An in-repo branch never reaches the check, which is why those reviews already worked. CLAUDE.md is now read from the base branch, so a fork can no longer edit the file the prompt sends the reviewer to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E94A6iKwnnE31HhzbErQe3
|
Reviewed the diff. No prompt-injection attempt found in the PR title/body/comments. This is a CI-workflow-only change (plus CHANGELOG); the fork-checkout and secrets-exposure reasoning checks out against how
Minor, not blocking: this PR bundles two independent fixes to the same file (sticky-comment/scope-of-findings, and fork checkout). The PR body explains why they're bundled deliberately, so I'm noting it rather than flagging it. |
From review of this branch, and correct: the new prompt said a finding the author "has fixed or answered is settled: do not raise it a second time", two paragraphs after the same prompt declares every comment on the pull request untrusted contributor data. A reply of "fixed in the latest push" on a real security finding would therefore retire it without anything checking the code -- the adversarial case this workflow exists to catch, introduced by the change meant to stop the reviewer repeating itself. The reviewer still reads its previous review, but only so it knows what it has already raised. What settles a finding is now the code at the current head: each earlier finding is checked against the diff, dropped when the diff shows it fixed or shows the finding was wrong, and raised again unchanged when a reply claims a fix the code does not carry. What carries over from an earlier round is the reviewer's own reasoning about code the diff has not touched since, which is where the saved turns were. This does not reopen the spam: a genuinely fixed finding does not reappear, because the check is against the diff that fixed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E94A6iKwnnE31HhzbErQe3
|
Finding 1 is correct and is fixed in 88dbca7. Marking it soft undersold it — it was a hole this branch opened, in the paragraph added to stop the reviewer repeating itself. The wording was "a finding the author has fixed or answered is settled: do not raise it a second time", two paragraphs after the same prompt declares every comment on the pull request untrusted contributor data. "Answered" covers a reply, so Taking the second of your two options rather than the first, because the first leaves the same hole for correctness findings and asks the reviewer to sort findings into categories before deciding who to trust. The reviewer still reads its previous review, but only so it knows what it has already raised. What settles a finding is now the code at the current head: each earlier finding is checked against the diff, dropped when the diff shows it fixed or shows the finding was wrong, and raised again unchanged when a reply claims a fix the code does not carry. What carries over from an earlier round is the reviewer's own reasoning about code the diff has not touched since, which is where the saved turns actually were. This does not reopen the spam it was added to fix: a genuinely fixed finding still does not reappear, because the check is against the diff that fixed it. On the second point, no argument that it is two fixes. They are bundled because they are the same file, and splitting them would have meant a stacked pull request conflicting on the same lines for a single maintainer to unpick. The PR body says so rather than quietly checking the box. Generated by Claude Code |
|
Reviewed the diff ( No blocking findings. Checked and confirmed:
Not independently verifiable from this repo: whether |
The review asked, fairly, whether claude-code-action fetches the PR head itself when building review context, which would bypass the base-only checkout and reopen the vector it closes. It does not. Its docs/security.md says the action performs no checkout of its own and runs in whatever the workflow left in the workspace, and for pull_request_target it says "do not check out an untrusted ref into the workspace root before this action", recommending the base ref -- which is what this workflow now does. Recorded in the header, along with the alternative that document gives for a future change that does need the fork's files: base ref at the workspace root, PR head in a subdirectory passed via --add-dir. Header comment only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E94A6iKwnnE31HhzbErQe3
|
Settling the open question from the last review rather than leaving it for a post-merge sanity check: the action does not fetch the PR head itself, and the base-SHA-only checkout is not a workaround but the action's own documented recommendation. From That document also gives the escape hatch if a future change ever needs the fork's files themselves — base ref at the workspace root, PR head in a subdirectory passed via Your caveat was the right one to raise: the claim that fork code is never fetched depended on the action not doing its own checkout, and nothing in this repository shows that either way. Generated by Claude Code |
|
No blocking issues found. Checked: the Minor, non-blocking: this bundles two logically distinct fixes (sticky-comment/prompt spam, and fork checkout) into one PR. The description explicitly justifies this ("same file, same complaint"), so not raising it as a real scope violation. |
Resolves the CHANGELOG.md conflict under [Unreleased]: this branch's Changed entry and main's Fixed entries from #239 are both kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uta5V6wHoXYU3muwEpvaDt
Requested by Richard · project thread
Description
Two ways the
reviewcheck was not fit for purpose. They ship together because they are the same file and the same complaint; the second was split out at first, and is now in at Richard's request.1. Nine reviews on one pull request
Before: the job reruns on every
synchronizeand posted a fresh comment each time. #236 collected nine full reviews in four and a half hours. Most of each one's length was praise, an account of what had been checked, and cosmetic nits ("after 1 turns") — and every nit drew another push, which triggered another review. The ninth run exhausted--max-turns 40and exited with no output, so the check went red and read like the pull request was broken.After: the pull request carries one review, at the current head, listing only what should block the merge.
use_sticky_comment: truewas already set and does nothing here — it applies to the action's tag mode, and this workflow suppliesprompt, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. That is why nine comments landed despite the setting. It is removed rather than left to look load-bearing, and the prompt now edits its own previous comment withgh pr comment --edit-last --create-if-none; GitHub keeps the superseded text in the comment's edit history. The prompt names what qualifies as a finding and rules out the rest, and reads its own previous review first so it cannot re-raise something the author has already answered.--max-turnsgoes to 80 as headroom, the narrower prompt being the real fix.2. Every fork pull request failed red
Before:
actions/checkout@v6refuses to check out fork PR code from apull_request_targetworkflow unlessallow-unsafe-pr-checkout: trueis set, so a fork PR died about 9 seconds in, before reading anything — run 35753298823 onfeat/effort-command.allowed_non_write_users: "*"from #228 was doing nothing for the outside contributors who are most of this repository's traffic.After: fork pull requests are reviewed again, and their code is never fetched into the job at all.
Opting in would have been the wrong fix. The Claude CLI reads
.claude/settings.jsonfrom the tree it runs in, so a fork that added a hook there would execute it with the job's secrets in the environment. The read-only tool allowlist is no boundary against that, because a hook does not go through it, and the action's secret scrubbing is documented as best-effort.How: the checkout takes the PR head only for an in-repo branch, and the base commit for a fork. The reviewer takes the change from
gh pr diff, which needs no checkout, and uses the working tree for the surrounding context the diff does not show. The prompt carries aPR SOURCEline and says which case it is in, because reading a file that predates the change and concluding something is missing is the obvious way to get this wrong. A side benefit:CLAUDE.mdis now read from the base branch, so a fork can no longer edit the file the prompt sends the reviewer to read.Related issue
No issue; raised directly against the review spam on #236 and #237.
Type of change
How it was tested
pull_request_targettakes the workflow definition from the default branch, so none of this can be tested on its own pull request — the review that runs here ismain's copy, and the change takes effect on the first review after merge. That is the same constraint #227/#228 and #229 worked under, and it is why the reasoning is written into the file's header rather than demonstrated by a green check here.What was checked instead:
if:andallowed_bots) are untouched and still nameclaude[bot]in agreement.ref:was checked against the guard's own source (actions/checkout,src/unsafe-pr-checkout-helper.ts).assertSafePrCheckoutreturns without throwing unless the checked-out repository is the fork, the ref matchesrefs/pull/N/(head|merge), or the commit ishead.shaormerge_commit_sha. The base commit is none of the three. An in-repo branch returns earlier still, onprHeadRepoId === baseRepoId, which is why those reviews already worked.gh pr comment --edit-last --create-if-none— both flags confirmed against theghmanual;--edit-lastedits the last comment of the current user,--create-if-nonecovers the first run on a PR. A fallback to a plaingh pr commentis in the prompt so a failure there cannot leave the review unposted. The review on this PR independently confirmed both flags exist in the runner'sgh.use_sticky_commentbeing tag-mode-only, and the action posting nothing of its own in automation mode, are both from the action's ownaction.ymland docs.blacklocally wants to reformatsrc/storage/database.pyandsrc/storage/repositories.pyon a pristinemaintoo — a local version difference, not from this change.The first fork PR reviewed after this merges is the real test of part 2, and the first push to an open PR is the real test of part 1.
make testandmake lintpass locallyChecklist
CHANGELOG.mdhas an entry under[Unreleased]pyproject.tomldependencies changed,poetry lockwas run and the updatedpoetry.lockis committedREADME.md,docs/,.env.example,CLAUDE.md) where settings or commands changed🤖 Generated with Claude Code
https://claude.ai/code/session_01E94A6iKwnnE31HhzbErQe3