Skip to content

review: never post to a merged or closed PR#213

Open
jwbron wants to merge 1 commit into
mainfrom
review-merged-pr-guard
Open

review: never post to a merged or closed PR#213
jwbron wants to merge 1 commit into
mainfrom
review-merged-pr-guard

Conversation

@jwbron

@jwbron jwbron commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

A review run takes ~12-17 minutes while PRs often merge minutes after their final push, so an in-flight run can complete after the merge and post a stale verdict. Real case on Khan/frontend#13088: the author pushed at 14:30, merged at 14:31, and the run posted CHANGES_REQUESTED with 11 blocking inline comments at 14:47 on the already-merged PR.

Nothing in the pipeline checks PR state: gh-aw's safe-output handlers post regardless of it (verified in submit_pr_review.cjs / pr_review_buffer.cjs at gh-aw HEAD; posting to closed targets is documented behavior), and the compiled per-PR concurrency group only cancels a run when a newer run of the same workflow starts, which a merge never produces.

The fix (two layers, frontmatter only)

  1. Cancel on close. Subscribe to pull_request: closed (fires on merge too) solely so the event enters the existing per-PR concurrency group (cancel-in-progress: true, workflow level) and cancels an in-flight review within seconds. The if: excludes closed runs from doing any work; the exclusion compiles onto the activation job, so every downstream job skips and the run costs nothing.
  2. Deterministic guard at post time. A safe-outputs.steps step runs in the safe-outputs job immediately before the posting handlers. If the PR is not open at that moment, it records why in the run's job summary and cancels the run, so nothing posts. This covers what layer 1 cannot: manual reruns of stale runs after a merge, and any cancellation miss.

Guard semantics: fails open on API errors (a transient GitHub error must never suppress a legitimate review of an open PR); fails closed once a non-open state is positively observed (if the cancel call is rejected, it fails the job so the handlers still never run). It checks only state, never recency: a run whose PR is still open posts normally even when a newer push exists, since that push's own run supersedes it.

Post-merge findings are deliberately discarded rather than rerouted (nobody acts on them today; see below); rerouting them to an issue/comment, or making the reviewer a blocking check, are separate policy decisions this change does not foreclose.

Why discard rather than keep or reroute the post-merge output

Measured over the last 1,000 merged Khan/frontend PRs (Dec 2025 through Jul 2026), 598 of which received a bot review:

  • The race fired 25 times (24 PRs, ~4% of reviewed merges), 10 of them in the last four weeks alone, so at current cadence it recurs two to three times a week and rises with adoption.
  • 24 of the 25 post-merge reviews were APPROVED (one COMMENTED) with 0-2 inline comments, all marked non-blocking. Exactly one was CHANGES_REQUESTED with blocking findings: Khan/frontend#13088, the incident above.
  • Human engagement with the post-merge output was zero in 23 of 24 PRs: no replies, no reactions, no thread resolutions, including on all 12 blocking threads of #13088. The one exception (Khan/frontend#12624) had the author replying "will fix" / "fixed in a subsequent PR", but to a different reviewer bot's post-merge comments, not this workflow's.

So post-merge posting produces output nobody acts on, at agent-run cost, with the occasional wrong blocking verdict on the record as the downside. If deliberate post-merge coverage is ever wanted, the right shape is a designed merged-trigger flow that files findings somewhere triaged, which this change does not preclude.

Consumers

Pick up by updating the pin and recompiling (gh aw update, commit the regenerated review.md + review.lock.yml). Requires gh-aw >= v0.81.6 at compile time (safe-outputs.steps is in its schema; consumers already compile with v0.81.6). No config.md changes, no new secrets: the guard reuses KHAN_ACTIONS_BOT_TOKEN (already required by resolve-thread/add-reviewer) because the safe-outputs job's GITHUB_TOKEN lacks actions: write for the cancel call.

No conflict with the open #196-#208 stack: it leaves the on:, if:, and safe-outputs: frontmatter regions untouched (its only frontmatter change is the pre-agent-steps lib checkout).

Test plan

  • Compiled with gh-aw v0.81.6 against Khan/frontend's real .github/aw/review/config.md: 0 errors; asserted in the lock that closed is in the trigger types, the exclusion landed on the activation job if:, the guard step renders before "Process Safe Outputs" with the bot token, and the concurrency group is unchanged.
  • Guard script passes node --check.
  • Manual repro after a consumer recompiles, on a sandbox PR:
    1. Open a trivial PR, wait for the agent job to be running, merge: the in-flight run flips to cancelled within ~a minute, the closed-event run skips all jobs, no review posts.
    2. Re-run the cancelled run on the now-merged PR: agent completes, the guard writes "Review not posted: PR #N was merged at ..." to the job summary, the run ends cancelled, the PR receives nothing.
    3. Control: a second open PR reviews and posts normally; two quick pushes still show the existing cancel-supersede behavior.

A review run takes 12-17 minutes while PRs often merge minutes after the
final push, so an in-flight run could complete after the merge and post a
stale verdict (seen in production: CHANGES_REQUESTED with 11 blocking
comments landing 16 minutes after the merge). Nothing checked PR state:
gh-aw's safe-output handlers post regardless of it, and the per-PR
concurrency group only cancels when a newer run of the same workflow starts.

Two layers close the race:

- Subscribe to pull_request closed (fires on merge too) solely so the event
  enters the existing per-PR concurrency group and cancels an in-flight
  review within seconds; the activation if excludes closed runs from doing
  any work, so every job skips and the run costs nothing.
- A safe-outputs.steps guard runs immediately before the posting handlers;
  if the PR is not open at that moment it records why in the job summary and
  cancels the run, covering manual reruns of stale runs and any cancellation
  miss. Fails open on API errors, fails closed once a non-open state is
  observed; checks only state, never recency, so open-PR reviews post
  normally even when a newer push exists.

Verified by compiling with gh-aw v0.81.6 (the version consumers pin) against
Khan/frontend's config: closed in the trigger types, the exclusion on the
activation job, the guard step rendered before Process Safe Outputs, and the
concurrency group unchanged.
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 205b1a0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@khan-actions-bot khan-actions-bot requested review from a team, jaredly and kevinb-khan and removed request for a team July 7, 2026 21:52

@jeresig jeresig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for handling this!

Comment on lines +168 to +180
try {
await github.rest.actions.cancelWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
});
// Wait for the runner to receive the cancellation and kill this step.
await new Promise((resolve) => setTimeout(resolve, 120000));
} catch (error) {
core.warning(`Could not cancel the run (${error.message}).`);
}
core.setFailed(
`PR #${prNumber} is ${pr.state}; refusing to post a review after the fact.`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me why this is needed - it seems like we could just intentionally fail the step, which would have the same effect as cancelling the rest of the workflow, I believe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants