Repository navigation
fix(claude-review): review caller-editing PRs with the job token, fail closed when nothing was reviewed - #300
Conversation
…l closed when nothing was reviewed claude-code-action exchanges the job's OIDC token for a Claude App token, and the exchange refuses any run whose calling workflow differs from the default branch's copy. The action then warns and exits 0 without reviewing (src/github/token.ts, src/entrypoints/run.ts at the a4f54ef pin), so `review / Claude Review` went green with nothing reviewed on every install PR and every caller edit. - Caller check: compare the caller (github.workflow_ref) at HEAD with the default branch's copy via the contents API. When it differs, is absent (an install PR), or cannot be read, pass `github_token: github.token`, which skips the exchange, so the PR gets a real review. The lost-findings guard's REVIEW_BOT follows: github-actions[bot] on those runs. - Review-ran check: step success with no `conclusion` output means nothing was reviewed. Write reviewed=false first, post or PATCH one sticky `<!-- claude-review:not-reviewed -->` comment, and fail the job. - selftest/test_claude_review_ran_check.sh runs the shipped bash against a stubbed gh and a real git checkout, and evaluates the step `if:`s and the token / REVIEW_BOT expressions. - README: the caller-PR caveat now describes the real behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut and the action SHA Codex pre-review round 2: - The review summary is a github-actions[bot] comment too, so a summary that quoted `<!-- claude-review:not-reviewed -->` would have been PATCHed over by the next not-reviewed run. The sticky lookup now requires the body to start with the marker (the pattern claude-author-automerge uses for its own sticky). - The selftest pins the default merge-ref checkout the caller check reads HEAD from, and the claude-code-action SHA whose token and skip behaviour both new steps rely on, so a bump has to re-read that source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sticky needs both markers Codex pre-review round 4: - On a job-token run the lost-findings guard counted every github-actions[bot] inline comment in the window, the identity every workflow's job token shares, so another workflow's comments could stand in for this review's lost findings. It now counts only comments at a path#line the review's own inline-comment calls named (the action posts path and line as given and rewrites the body; an outdated comment keeps the line as original_line). A transcript it cannot read leaves the count unfiltered. App-token runs are unchanged. - The not-reviewed sticky now also ends with `<!-- claude-review:not-reviewed:end -->`, and the lookup requires both markers. The model writes the start of the review summary, but the summary step appends its own footer last, so no summary can match. - The review-ran comment no longer rules out the validation skip when the caller matched: a caller change can land on the default branch between the caller check and the token exchange. That race fails closed, and a re-run reviews with the job token; documented at the caller check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…attribution Codex pre-review round 5 asked to match job-token inline comments by more than path and line. Not taken: the action's sanitizer rewrites bodies, so body matching would misfire on ordinary reviews, and buffered comments post after the session, so their IDs never reach the transcript. The residual (another workflow commenting on the very same path and line within the review window while this review's comment was lost) is now named at the filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on when the caller matched Independent review: whether the action's validation compares the caller in the merge commit this run checks out or another commit of the PR is not documented. The comment for the matched-caller case now names both ways a validation skip could still happen (the default branch changed during the run, or another commit was read) and says to update the branch, instead of ruling the skip out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Risk class: This PR touches one of the blocked path categories from Auto-merge is refused by (This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.") |
|
The Summary of findings: The caller check correctly fails safe ( No issues found. The logic is fail-safe throughout and the selftest covers the key edge cases well. Claude Review · workflow run |
Problem
anthropics/claude-code-action(pinned ata4f54ef, v1.0.229) gets its GitHub token by exchanging the job's OIDC token for a Claude GitHub App token. The exchange refuses any run whose calling workflow differs from the default branch's copy ("workflow validation"). The action then logs a warning and returns without failing its step (src/github/token.ts,src/entrypoints/run.ts).So
review / Claude Reviewconcludessuccessin about 30 seconds with nothing reviewed on every install PR and every PR that edits its caller, and every other file in those diffs goes unreviewed too. Where a ruleset requires the check, that green satisfies it. Fleet evidence (private): topcoder1/dotclaude#519.Change
C: review those PRs with the job token. A new "Caller check" step runs before the review.
github.workflow_ref(in a reusable workflow thegithubcontext is the caller's). It then compares the caller's blob at HEAD (git rev-parse HEAD:<path>, the merge commit the run took its caller from) with the default branch's copy, read through the contents API.changed=falseonly on a proven match. A different blob (an edit), a 404 "Not Found" (an install PR) or any read failure giveschanged=true, which is fail safe: the review runs.changed != 'false', the review step passesgithub_token: ${{ github.token }}.setupGitHubToken()returns a given token before it requests an OIDC token, and the action's "Revoke app token" step runs only wheninputs.github_token == ''(action.yml). Every other PR keeps the App identity.permissions:(contents: read, pull-requests: write). Nothing was widened. Comments made with it start no workflow runs.github-actions[bot], so the lost-findings guard'sREVIEW_BOTfollows the same predicate. Every workflow's job token shares that identity, so on job-token runs the guard counts only inline comments at a path:line that the review's own inline-comment calls named. App-token runs are unchanged.continue-on-error: true. If it dies before writing its outputs, the predicate still selects the job token and the review runs.B: fail closed when nothing was reviewed. A new "Review-ran check" step runs after the review.
successwith noconclusionoutput reviewed nothing. On the skip path the action sets neitherconclusionnorexecution_file. A real run setsconclusion, and any failure fails the step.reviewed=falsefirst. It then posts, or on later runs PATCHes, ONE sticky comment that says what the caller check found, and exits 1.github-actions[bot]comment that starts with<!-- claude-review:not-reviewed -->and ends with<!-- claude-review:not-reviewed:end -->. The review summary can never match it: the model writes the summary's start, but the summary step appends its own footer last.::and##[broken. The verdict stands either way.README: the "Install PR's
Claude Reviewcheck may fail" caveat now describes the real behaviour.Testing
A ci-workflows PR cannot exercise its own reusable change, because its caller pins
@main. The selftest is the proof.selftest/test_claude_review_ran_check.sh(new, 36 cases, added to the pytest wrapper list):gh.if:s and thegithub_token/REVIEW_BOTexpressions with a small GitHub-expression evaluator, forchanged= true, false, empty and unset, and on the bot-skip, failed-review and cancelled paths.&& '' ||falsy-middle trap.mainworkflow, each fail the selftest. Examples: exit 0 instead of 1; the decision written after posting; a staticREVIEW_BOT; the falsy-middle token expression; nocontinue-on-error; unchanged as the default; the author or marker filters loosened; no PATCH path; log prefixes kept; a blind post; a missing default-branch ref; any 404 read as an install;conclusion=failureread as unreviewed; an unvalidated caller path; a checkout of the PR head; an unpinned action; the guard filter off; the end marker dropped.uv run pyteston this head: 768 passed, 1 skipped. Baseline onmainbefore fix(audit-required-checks): fail loudly on API errors, file findings apart #299 landed: 766 passed, 1 skipped; fix(audit-required-checks): fail loudly on API errors, file findings apart #299 and this PR each add one wrapper entry./bin/bash3.2.gh api -X GET repos/…/contents/<path> -f ref=<branch>returns the same blob sha asgit rev-parse HEAD:<path>. A missing file is a 404 "Not Found"; a missing ref is a 404 "No commit found for the ref".Accepted, not addressed (from the Codex pre-review)
cancel-in-progressconcurrency bounds it (see the guard's OVERLAP NOTE). Anchoring on the reviewed commit would not close it: the action posts buffered comments on the PR's head at posting time (src/entrypoints/post-buffered-inline-comments.ts), so a cancelled run's late comments carry the new head.After merge
Auto-merge rationale: manual click-merge: every PR to topcoder1/ci-workflows is manual, and this changes the reusable behind a fleet-wide required check.
Codex rounds: 6 (cap reached). Round 1 clean; round 2 three P2, fixed; round 3 clean; round 4 two P1 and one P2, two fixed and the race documented; round 5 one P2 (a narrower residual of round 4), documented; round 6 one P1 and two P2, dispositioned above (the P1 is the existing overlap limit).
🤖 Generated with Claude Code