Skip to content
Merged
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
88 changes: 47 additions & 41 deletions .github/actions/pr-review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ runs:
REVIEW_PROMPT: ${{ inputs.review_prompt }}
SUMMARY_MARKER: ${{ inputs.summary_marker }}
run: |
# Captured before any review work: the stamp/submit gates require the
# summary comment to have been created/updated at or after this moment,
# so a successful agent step can never launder a prior run's summary
# into this run's verdict.
# Captured before any review work: the publication gates require the
# working summary comment to have been created/updated at or after this
# moment, so a successful agent step can never launder a prior run's
# output into this run's report.
echo "review_run_started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
case "${REVIEW_PROMPT}" in
""|"connector")
Expand Down Expand Up @@ -127,7 +127,13 @@ runs:
anthropic_api_key: ${{ inputs.anthropic_api_key }}
github_token: ${{ inputs.github_token }}
include_fix_links: true
use_sticky_comment: true
# use_sticky_comment is OFF: the review's working summary comment is
# managed explicitly (summary_comment_id from fetch-pr-context.py), and
# completed reports are published as NEW comments by
# publish-review-report.py. Sticky reuse must never let
# claude-code-action's tracking pick up and overwrite a completed
# report comment.
use_sticky_comment: false
allowed_bots: "*"
# --setting-sources user: do NOT load the reviewed repo's project/local
# settings. Those register the repo's own .claude/agents and .claude/commands
Expand All @@ -147,51 +153,51 @@ runs:
# schedules a wakeup no event loop will fire, so the agent ends its turn
# waiting and posts no summary.
#
# Bash(gh pr review:*) is gone from the allow-list: CI submits the verdict
# deterministically (submit-verdict-review.py) instead of relying on the
# agent to run a trailing command, which Claude Code upgrades have
# repeatedly regressed (the agent stops after the summary and the formal
# review is never submitted).
# Bash(gh pr review:*) is gone from the allow-list: CI publishes the
# report and submits the verdict deterministically
# (publish-review-report.py) instead of relying on the agent to run a
# trailing command, which Claude Code upgrades have repeatedly
# regressed (the agent stops after the summary and the formal review is
# never submitted).
claude_args: --model claude-opus-5-5 --max-turns 100 --setting-sources user --strict-mcp-config --disallowedTools "Skill,ScheduleWakeup,CronCreate,CronDelete,CronList" --allowedTools "Read,Glob,Grep,Task,mcp__github_inline_comment__create_inline_comment,mcp__github_comment__update_claude_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api:*)"
prompt: ${{ env.REVIEW_PROMPT }}
- name: Stamp review-state on summary comment
id: stamp
# Bind the sticky summary comment to the reviewed HEAD deterministically.
# submit-verdict-review.py requires a <!-- review-state --> marker matching
# HEAD, and fetch-pr-context.py requires its workflow_ref to match — but the
# agent does not emit the marker reliably, so state detection failed closed
# (every run fell back to full mode) and no verdict could be submitted.
# This runs only after a successful agent review of the checked-out head, so
# git HEAD is exactly what was reviewed.
- name: Publish review report and verdict
id: publish
# CI finalizes the run deterministically rather than relying on the agent
# to stamp metadata or run `gh pr review` itself (those trailing model
# steps regressed repeatedly — the agent stops after the summary and the
# formal review is never submitted). This step:
# 1. selects THIS run's fresh, final working summary (never a completed
# report — those are CI-published output the model must not mutate —
# and never output already consumed by a published report), refusing
# as obsolete when a later attempt already completed (actual attempt
# start times, never run-ID order),
# 2. validates the baseline count row and that the live PR head still
# equals the reviewed checkout,
# 3. POSTs a NEW report comment (publication: pending) carrying a visible
# reviewed-commit link and CI-owned review-state metadata identifying
# the publication (workflow + marker + mode + run + attempt + head),
# 4. submits the formal PR review with an explicit commit_id, linking
# directly to the report — baseline mode only: request changes on
# blocking findings, neutral comment otherwise, never approves,
# 5. transitions the report to publication: completed — a completed
# report is the report PLUS its formal result, so a report whose
# review never landed stays pending and never becomes review state,
# 6. only then collapses the superseded previous report(s) and the
# consumed working comment (bodies retained, linked to the new
# report).
# Publication is idempotent per run/attempt: a repeated finalization
# reconciles the published report and review by identity and never
# submits a second review. Any gate failure exits nonzero — a broken
# review is a loud red check, not silent green.
if: steps.claude_review.conclusion == 'success'
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
PR_NUMBER: ${{ inputs.pr_number }}
SUMMARY_MARKER: ${{ steps.review-config.outputs.summary_heading }}
REVIEW_RUN_STARTED_AT: ${{ steps.review-config.outputs.review_run_started_at }}
run: python3 ${{ github.action_path }}/scripts/stamp-review-state.py
- name: Submit verdict review
id: submit_verdict
# CI submits the formal PR review from the **Blocking Issues: N** count in
# the agent's summary comment, rather than relying on the agent to run
# `gh pr review` itself (that trailing step regressed with Claude Code
# upgrades — the agent stopped after posting the summary, so PRs got a
# quiet comment and no blocking review). Baseline mode only: request
# changes on blocking findings, neutral comment otherwise — never approves.
# Gates: the summary must be fresh (this run), final (not provisional),
# owned by this workflow, bound to the reviewed commit, and the live PR
# head must not have moved; the review is posted via the REST API with an
# explicit commit_id. Any gate failure exits nonzero — a broken review is
# a loud red check, not silent green.
if: steps.claude_review.conclusion == 'success'
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
PR_NUMBER: ${{ inputs.pr_number }}
SUMMARY_MARKER: ${{ steps.review-config.outputs.summary_heading }}
REVIEW_RUN_STARTED_AT: ${{ steps.review-config.outputs.review_run_started_at }}
run: python3 ${{ github.action_path }}/scripts/submit-verdict-review.py
run: python3 ${{ github.action_path }}/scripts/publish-review-report.py
- name: Upload review context artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
Expand Down
37 changes: 24 additions & 13 deletions .github/actions/pr-review/prompts/base-pr-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ _⏳ Provisional — deeper review still in progress._
```

The provisional summary is progress output, not a verdict. Do not emit review-state
metadata in either summary: CI stamps the reviewed commit, base, and workflow after
you publish the final summary. CI refuses a comment still marked provisional, so
leave that line only while the review itself is incomplete. Once the review and
final-comment publication are complete, remove it; do not wait for CI's metadata.
metadata in either summary: CI attaches the reviewed commit, base, workflow, and
publication metadata when it publishes the completed report. CI refuses working output
still marked provisional, so leave that line only while the review itself is
incomplete. Once the review and final-comment publication are complete, remove it;
do not wait for CI's metadata.

Then keep working and replace it with your final summary, dropping the
provisional line. If the run is killed mid-review, the provisional summary
Expand Down Expand Up @@ -68,7 +69,9 @@ Read `.github/pr-context.json` — it contains pre-fetched PR data with these fi
- `summary_heading`: the exact markdown heading for the summary comment
- `review_mode`: `"incremental"` or `"full"`
- `last_reviewed_sha`: the SHA from the previous review, used only for deduplication
- `summary_comment_id`: the existing bot summary comment to update, if one exists
- `summary_comment_id`: the existing WORKING summary comment to update, if one
exists — an earlier in-progress run's provisional or unfinished comment.
Completed published reports are never handed to you as update targets.
- `incremental_diff_path`: path to a GitHub API compare diff when incremental review is available
- `incremental_diff_metadata`: metadata about filtered incremental diff coverage,
including dropped vendored/generated/lockfile paths and truncation state
Expand Down Expand Up @@ -273,6 +276,13 @@ If it is not set, create one the same way against
`repos/<repository>/issues/<pr_number>/comments`.
Do not delete existing summary comments before the new review has been posted.

The comment you post is this run's WORKING summary. At completion, CI publishes the
completed report as a separate new comment (carrying the reviewed-commit link and the
CI-owned review-state metadata), submits the formal review linking to it, and then
collapses the working comment and the superseded prior report. Never edit a comment
that already carries a `<!-- review-state: ... -->` marker — it is a completed
report, not your working slot.

Use this template for the summary body. The heading must be exactly the `summary_heading`
value from `.github/pr-context.json`.

Expand Down Expand Up @@ -330,10 +340,10 @@ here. Omit this section when no prior findings were fixed or made obsolete.>
```

Use `review_run_url` from `.github/pr-context.json`; omit the link if it is empty.
CI owns the review-state marker and formal review submission. Do not try to write
that marker, create a file to carry it, or leave a completed review provisional
because the marker is absent. Publish the findings and final summary; CI attaches
the metadata afterward.
CI owns the review-state marker, the completed report, and formal review submission.
Do not try to write that marker, create a file to carry it, or leave a completed
review provisional because the marker is absent. Publish the findings and final
working summary; CI posts the completed report with the metadata afterward.

After the summary body, include a collapsible section with a single fenced code block
that lists every finding as a concise, actionable description a developer can follow
Expand Down Expand Up @@ -372,10 +382,11 @@ specific fix in plain English. If there are no findings, omit this section entir

**Verdict:** CI submits the formal PR review for you — do NOT run `gh pr review`
yourself. After you post the final summary, CI reads the `**Blocking Issues: N**`
count from it and submits `--request-changes` when N > 0 or `--comment` when
N == 0. Your only obligation is an accurate count and a complete summary; a
missing or malformed count turns the whole run red, so always post the summary
in the exact template above.
count from it, publishes the completed report, and submits a commit-bound
`--request-changes` when N > 0 or `--comment` when N == 0, linking to the report.
Your only obligation is an accurate count and a complete summary; a missing or
malformed count turns the whole run red, so always post the summary in the exact
template above.

## Review Criteria

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/pr-review/scripts/_gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ def _status_link(source: str | None) -> str | None:
# --------------------------------------------------------------------------- #
# Review-stage failure marker. #
# #
# A review-stage step (stamp / submit-verdict) that fails cannot post the #
# A review-stage step (context / publish) that fails cannot post the #
# outage/incomplete notice itself without racing the always() "classify" #
# step, which would double-post. Instead the failing script drops a small #
# JSON marker describing WHY it failed; the single classify step reads it and #
Expand Down
Loading