Skip to content

fix: evaluate backport approval from live labels to prevent check race - #422

Merged
jkleinsc merged 1 commit into
mainfrom
fix/backport-approval-check-race
Aug 19, 2026
Merged

fix: evaluate backport approval from live labels to prevent check race#422
jkleinsc merged 1 commit into
mainfrom
fix/backport-approval-check-race

Conversation

@claude

@claude claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Requested by John Kleinschmidt · Slack thread

Before: on a manually-opened backport PR, concurrent webhook deliveries could create duplicate "Backport Approval Enforcement" check runs, and a slow invocation holding a stale label snapshot could stamp the latest run "Backport Approval Not Required" — even though trop itself had just added backport/requested 🗳 — letting auto-merge proceed without approval. This is exactly what happened on electron/electron#52973.

After: the verdict is evaluated from the PR's live labels at write time, a labeled/unlabeled event always writes the correct state to the existing run (including completing/resetting one that is already queued), and duplicate check runs are no longer created.

This makes the "Backport Approval Enforcement" check evaluate against live labels and write idempotently to a single check run, closing the race that let a backport requiring approval merge with a green check.

How

  • Live labels instead of the payload snapshot (src/index.ts): the approval verdict (backport/approved ✅ / backport/requested 🗳) is now read via labelExistsOnPR (issues.listLabelsOnIssue) rather than context.payload.pull_request.labels. The payload is frozen at delivery time, so for a manual backport it can never contain the backport/requested 🗳 label that updateManualBackport adds mid-handler — the very invocation that requested approval would conclude "Not Required".
  • No more queued no-op (src/index.ts): the pending branch previously returned early when the run's status was already queued, trusting a snapshot that a concurrent stale invocation could later overwrite. It now always (re-)asserts the pending state idempotently, so a stale conclusion gets corrected instead of standing forever. Relatedly, when backport/approved ✅ is removed and backport/requested 🗳 is re-added, the check now goes back to pending rather than briefly concluding success.
  • Duplicate-run dedupe (src/utils/checks-util.ts): queueBackportApprovalCheck re-lists the check runs for the head SHA immediately before writing and, if a run named "Backport Approval Enforcement" already exists, resets it to queued by run id instead of creating another run (six duplicates were created within 11 seconds during the incident). This narrows the check-then-create race substantially; fully atomic creation isn't possible with the Checks API, but with the verdict now computed from live labels a rare duplicate can no longer carry a stale conclusion.
  • Tests: new regression tests cover (1) a manual backport where backport/requested 🗳 is only present in live labels (post-snapshot) staying pending instead of concluding "Not Required", (2) a labeled event on an already-queued run re-asserting the pending state instead of no-op'ing, (3) a labeled event completing a queued run when backport/approved ✅ is added, and (4) queueBackportApprovalCheck updating an existing run instead of creating a duplicate.

The approval-request gate itself (shouldRequestBackportApproval, added in #417) is unchanged — this bug is in how the check run's conclusion was computed and written, not in the gate.

Refs: electron/electron#52973, #417


Generated by Claude Code

On manually-opened backport PRs, the shared pull_request handler evaluated
the Backport Approval Enforcement verdict from the webhook payload's label
snapshot, which is frozen at delivery time and can never contain the
backport/requested label that updateManualBackport adds mid-handler. A slow
invocation carrying a stale payload could then complete the check run last
with 'Backport Approval Not Required', and the labeled-event safety net
no-op'd whenever the run was already queued, so the stale conclusion stood
and auto-merge could proceed without approval.

- Evaluate the approval verdict from live labels fetched via the API
  (labelExistsOnPR) instead of the payload's label array.
- Replace the queued no-op with an idempotent write of the pending state,
  so a labeled/unlabeled event corrects a stale conclusion instead of
  returning early.
- Deduplicate check-run creation: queueBackportApprovalCheck now re-lists
  check runs for the head SHA and resets the existing run to queued by id
  instead of creating another run.
- When backport/approved is removed, keep the check pending after
  re-adding backport/requested rather than stamping success.
@jkleinsc
jkleinsc marked this pull request as ready for review August 19, 2026 18:14
@jkleinsc
jkleinsc requested a review from a team as a code owner August 19, 2026 18:14
@jkleinsc
jkleinsc merged commit 666b47b into main Aug 19, 2026
6 checks passed
@jkleinsc
jkleinsc deleted the fix/backport-approval-check-race branch August 19, 2026 18:58
dsanders11 pushed a commit that referenced this pull request Aug 25, 2026
…ting them (#423)

* fix: supersede completed backport approval check runs instead of updating them

The Checks API treats a completed check run as terminal: a PATCH asking to
move it back to 'queued' succeeds and applies the output, but silently keeps
the old status and conclusion. The dedupe path added in #422 therefore could
not un-green a run that had already concluded success - the labeled-event
reset only rewrote the output text, leaving a green check whose output read
'Needs Backport Approval' (observed on electron/electron#53035).

- queueBackportApprovalCheck now only updates an existing run in place while
  it is still pending; a completed run is superseded by a fresh queued run,
  which branch protection consults as the latest run per name.
- The opened-event evaluation no longer concludes 'not required' for
  backport PRs authored by trop itself: trop adds labels in a separate API
  call shortly after creating the PR, so the live labels are still empty in
  that window. The check stays queued until the labeled events that follow
  trop's own label writes settle the verdict.

* fix: keep opened backport approval check pending for all declared backports

The opened-event guard special-cased backports authored by trop's bot
user, but the window it protects against is not author-specific: a
declared backport's labels are always written by trop itself -
backportImpl labels trop-created backports in a separate API call after
opening them, and updateManualBackport labels manually-opened backports
with at least the base-ref label - so labels can land after the opened
delivery for any author, and webhook deliveries can be delayed or
reordered besides.

Gate the guard on the author-agnostic property that actually matters:
whether the PR body declares 'Backport of #N'. Every declared backport
is guaranteed at least one trop-written label and therefore a labeled
event that settles the verdict, so opened can safely leave the check
pending. PRs without a declaration (e.g. fast-track PRs targeting
release branches) get no guaranteed labeled event and would hang queued
forever, so opened still concludes 'not required' for them.

Also skip the re-queue when the check has already concluded: a labeled
delivery processed before a late opened delivery has already settled
the verdict from the same live labels, and superseding that concluded
run would leave a queued check no follow-up event ever completes.

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

3 participants