Uh oh!
There was an error while loading. Please reload this page.
ci(conflict-gate): a conflicted PR reads red, not empty-green (backend#2637) - #359
Conversation
…d#2637) A PR with a merge conflict against its base dispatches NONE of its `pull_request` workflows: GitHub cannot compute the merge ref those runs are keyed on. Every drift and source-of-truth guard is therefore silently inactive on exactly the PRs most likely to need it, and the rollup shows nothing red because nothing ran. Measured on model-zoo#206 while it was DIRTY against develop: 0 workflow runs on its head sha, one rollup entry (Cursor Bugbot, SUCCESS), and all seven of model-zoo/develop's required contexts absent. PRs #204 and #205 either side of it got their full matrix. Adds an org-wide sweep that writes a commit status onto each open PR's head sha - failure when conflicted, success when clear, pending when GitHub will not say - which is the only signal that can reach a PR with no merge ref. Not required anywhere yet, deliberately: a 30-minute sweep would leave a freshly-opened PR at "Expected - waiting" until the next run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ackend#2637) Two mutations -- drafts skipped in plan(), and an unreadable PR list returning no error -- emptied a list the suite then indexed, so it died with an IndexError instead of reporting a FAIL. The harness correctly scored both as 'broke the harness' rather than caught: a traceback proves nothing about coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r cannot see (backend#2637) backend#2257, measured live and CONFLICTING, carries 8 workflow runs and all eleven of backend/develop's required contexts present and SUCCESS -- computed against a merge base that no longer exists. bricked-prs.py reasons from an ABSENT required context, so with nothing missing it cannot see this PR at all. That is the case that justifies asking about mergeability directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ething (backend#2637) GitHub caps statuses at 1000 per sha AND context. A 30-minute sweep is 48 writes a day onto an unchanged head, so a PR left open three weeks would exhaust the cap and every later write would 422 -- the gate going silent on exactly the stalest PRs, which are the ones most likely to have conflicted. The current state is read out of statusCheckRollup, which the PR list already returns, so this costs no extra API call. A truncated rollup omits our context, which reads as 'no status yet' and produces a write -- the safe direction. The case fold is load-bearing: GraphQL reports SUCCESS, the Statuses API takes success, and comparing unfolded would make every status look changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 27, 2026
Pushed GitHub caps statuses at 1000 per sha and context. A 30-minute sweep is 48 writes a day onto an unchanged head, so a PR left open ~3 weeks would exhaust the cap and every write after that would A status is now written only when it would change something. The current state comes out of
The direction that matters most is clearing: a stale Updated numbers: Fleet dry-run on the current head, writing nothing:
|
… (backend#2637) Everything the suite asserted proved the SCRIPT was right; none of it proved anything RUNS it. Adds nine assertions parsing conflict-gate.yml as YAML (a "run:" line under a comment or an "if: false" greps identically to a live one) and six workflow mutations, so the harness now rewrites both files. The regression most likely to be made in good faith has its own mutation: someone asks why this does not run on PRs, adds a "pull_request:" trigger, and the gate becomes inert on exactly the conflicted PRs it exists for while looking more thorough. Rule 5 does not exempt a guarantee for being written in YAML. The baseline guard now covers both targets: a mutation left in the workflow by a killed run would become the next run's premise just as silently as one left in the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c3b8279. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 27, 2026
Pushed Adds nine assertions that parse The regression most likely to be made in good faith has its own mutation: someone asks why doesn't this run on PRs?, adds Also pinned there: the mint asks for exactly |
…(Bugbot, #359) Bugbot raised this as high on the previous head, and it was right. "gh pr list --json statusCheckRollup" resolves "commit.status" underneath, and GraphQL REFUSES that subfield on a PRIVATE repo unless the token also holds "actions: read" -- measured under backend#2157 and documented in bricked-prs.yml, which declares "permission-actions: read" for exactly this reason. This gate's mint deliberately holds only pull-requests:read and statuses:write, so the dedup added in c3b8279 would have made open_prs raise on every private repo in the org: each becomes COULD NOT EVALUATE and the sweep exits 2 having judged almost nothing. The trap was documented in a file I read while writing this one, which is the argument for measuring a narrow token rather than reasoning about it. Reads "GET /repos/{o}/{r}/commits/{sha}/status" instead. It reads commit statuses and nothing else, so the statuses permission already held covers it; it cannot be refused for a scope this job has no other use for; and it has no pagination cap to straddle. One GET per open PR, ~50 per sweep. The mint stays at two permissions. Two new mutations pin it: switching back to the rollup, and treating an unreadable current state as agreement (which would silently stop reporting whenever the status read flakes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aptracebloc
left a comment
There was a problem hiding this comment.
Approving. This closes a genuinely nasty blind spot and the test suite is among the most rigorous I've reviewed.
The "empty-green" bug is real and well-diagnosed: a PR with a merge conflict dispatches none of its pull_request workflows (GitHub can't compute the merge ref they're keyed on), so every drift/source-of-truth guard silently goes inactive on exactly the PRs that most need it, and the rollup shows nothing red because nothing ran. Reaching those PRs from outside via a scheduled org-wide sweep that writes a conflict-gate / mergeable commit status onto the head sha — failure on conflict, success when clear (self-clearing, so it can never brick a healthy PR), pending when GitHub won't answer — is the right shape.
The UNKNOWN handling is the load-bearing part and it's correctly fail-closed: classify rejects any non-enum value to undetermined, lets affirmative conflict evidence win over an UNKNOWN in the other field (so CONFLICTING + UNKNOWN reads CONFLICTED, not dropped), and keys clear only on mergeable == MERGEABLE — never on a bare UNKNOWN. Every residual edge (missing head sha, failed write, unreadable list, truncation cap) fails closed to exit 2, never a silent clean sweep. I ran the mutation harness: 115/115 self-test assertions pass and 29/29 mutations are caught, including the "any UNKNOWN wins" and "treat absent as CLEAR" mutations; the workflow is verified as parsed YAML (token scope pinned to exactly statuses:write + pull-requests:read), not grepped. No wedge risk — it's deliberately not required anywhere yet ("arm while green"), and every swept PR gets a status so nothing parks at "Expected."
Three low, non-blocking fast-follows:
scripts/conflict-gate.py:290—existing_statereads the combined-status endpoint (statuses paginated 30/page), but the comment claims "no pagination cap to straddle." On a sha with >30 status contexts the gate's own context can land on page 2 and be re-written every sweep. Safe direction (extra writes, never a wrong verdict), but the comment is factually wrong.Makefile:167—mutation-conflict-gateomits theguard-pyyamldep its selftest sibling has; confusing standalone failure without pyyaml..github/workflows/conflict-gate.yml:122— exit 1 on any conflict with nocontinue-on-errormakes the cron red on the common case, so a real exit-2 operational failure looks the same as "found conflicts" at the run-list level. Mirrorsbricked-prs.py, so it's the house convention — flagging, not blocking.
(Minor: the PR body's evidence cites 92 assertions / 17 mutations — stale; HEAD actually has 115 / 29, i.e. more coverage than advertised.)
— drafted with Claude Code
LukasWodka
commented
Aug 27, 2026
@aptracebloc thank you for the review — and apologies, the approval cannot unblock this PR and that is our config's fault, not yours. Measured just now: GitHub only counts approving reviews from collaborators with write access toward Ruled out, so nobody re-diagnoses this: @saadqbal — requesting you, since you hold The wider problem, worth fixing once rather than per-PR. On |
LukasWodka
commented
Aug 27, 2026
@aptracebloc — thank you for this review, and I owe you a heads-up: your approval cannot unblock this PR, through no fault of yours. You hold Nothing in the PR view, the protection API or the checks list says why — the only way to find it is to query collaborator permission per user per repo. It is not just this repo or just you. Measured today:
Filed as backend#2734 — it is the same class this repo keeps writing guards against: a mechanism that renders as doing the thing and is not connected to what it gates. Sorry it cost you a careful read on a PR it could never merge. @saadqbal is requested and has write, so this merges on his approval. — drafted with Claude Code |
saadqbal
left a comment
There was a problem hiding this comment.
This is the deepest version of a failure this org has been finding all week, and the one that could not be fixed from inside: a conflicted PR dispatches nopull_request workflows, so every drift and source-of-truth guard is inactive on exactly the PRs most likely to need one — and the rollup shows nothing red because nothing ran. Naming it as platform behaviour rather than a YAML bug is what makes the architecture right: no trigger change can produce a merge ref that does not exist, so the only signal that can reach a conflicted PR is one written onto its head sha from outside.
The two cases being different shapes is the part that makes the measurement worth having.model-zoo#206 with total_count: 0, a rollup containing exactly one entry — Cursor Bugbot = SUCCESS — and seven required contexts simply absent, with the neighbouring PRs either side carrying full matrices so the conflict is the only variable. And backend#2257 worse: eight runs on the head sha, unanimously green, while conflicted. One is an empty rollup, the other a stale-but-affirmative one; a fix that only addressed the first would have left the second reading clean.
Everything I would have gone looking for is here, and reasoned rather than merely present:
Three states, and pending for "cannot tell"."undetermined -> pending — 'cannot tell' is neither, and says so", with the note that pending blocks a merge exactly as failure does, so honesty about the unknown costs no safety. Choosing not to assert a conflict it cannot see is the right call in both directions.
success mattering as much as failure. Writing the context on clear PRs is what lets it clear itself, so a resolved conflict can't leave a permanent red — and it makes the context's absence meaningful, which a failure-only writer would destroy.
Two fields of conflict evidence, without letting one field's UNKNOWN suppress the other's affirmative answer. That's the trap in reading mergeable and mergeStateStatus together, and it's closed explicitly rather than by luck.
Knowing that the read is what schedules the computation, so a first-read UNKNOWN on both fields is expected rather than a real unknown — "answer, not a workaround. Without this every sweep would paint pending over" everything.
And the recursive one, which is the mark of the thing:"WHEN IN DOUBT, WRITE. An unreadable current state returns None, which equals no status … empty-green, which is the failure this whole file exists to remove." A tool whose own read failure reproduces the bug it fixes is the commonest way this kind of guard dies, and it's handled.
One correction of mine: I reported this merged two passes ago. It never was — it dropped out of my filtered view and I read the absence as a merge, which is the second time I've done that this session and a fitting error to make on a PR about absence rendering as green.
Green, no threads. 👍
Uh oh!
There was an error while loading. Please reload this page.

Summary
A PR with a merge conflict against its base dispatches none of its
pull_requestworkflows. GitHub cannot compute the merge ref those runs are keyed on, so every drift / source-of-truth guard is silently inactive on exactly the PRs most likely to need one — and the rollup shows nothing red, because nothing ran.This is platform behaviour, not a bug in our YAML: there is no merge commit for those jobs to check out, so no trigger change can fix it. The only signal that can reach a conflicted PR is one written onto its head sha from outside. This adds that: an org-wide sweep that writes a
conflict-gate / mergeablecommit status onto every open PR.The hole is real and open — measured, not assumed
Two live cases on 2026-08-27, and they are different shapes:
1.
model-zoo#206— nothing ran.mergeableCONFLICTINGactions/runs?head_sha=e7465eatotal_count: 0Cursor Bugbot=SUCCESSmodel-zoo/developrequiresThe conflict was the only difference. Bugbot reviews the diff rather than via a
pull_requesttrigger, so it is the one voice left — and it says green.2.
backend#2257— worse: unanimously green while conflicted.mergeableCONFLICTINGbackend/develop's 11 required contextsSUCCESSIts head sha never changed, so it kept every check it had already earned — all computed against a merge base that no longer exists. This is the
client#847shape from the ticket: checks that reported before the conflict appeared, with aversion-bump-gatefailure invisible for ~3 hours.Case 2 is why
bricked-prs.pyis not already the answer. That watcher infers a problem from a required context being absent (missing = required - present), and itsconflictedcause only ever attaches to amissingfinding. Here nothing is missing, so it cannot see this PR at all. Its necessary 60-minute grace window also means it says nothing about a fresh conflict. The two files answer different questions; neither subsumes the other.Approach chosen — option 2 from the ticket, as visibility
A commit status written from outside the PR, on a scheduled org-wide sweep:
conflicted→failure— the PR finally has something red to point atclear→success— the context clears itself, so it can never brick a healthy PRundetermined→pending— "cannot tell" is neither, and says soRejected, and why
pull_request-triggered job — that is the defect. There is nothing for it to run against.pull_request_target— needs no merge ref either, but nothing in this org uses it: measured 2026-08-27,actions/runs?event=pull_request_targetreturnstotal_count: 0fleet-wide, so its behaviour on a conflicted PR is unverified here. Building a fail-closed guard on an unmeasured platform claim is CLAUDE.md rule 8's mistake.pushto the base branch — the causally exact trigger (a conflict is created when base moves), but apushhere fires only for this repo. Covering all 17 would need a per-repo caller; that is a latency improvement, not a correctness one, and is the natural follow-up.developalready carries 6–12 required contexts (backend11,client10,model-zoo7). That is why the merge path is not the exposure; withdismiss_stale_reviews: truefleet-wide, resolving a conflict also re-triggers the guards and dismisses the approval. What was left was visibility, which is what this ships.bricked-prs.py— it is a read-only 4-hourly audit with a deliberate 60-minute grace window; all three are wrong for a per-PR signal, and mixing them would degrade the watcher.Not required anywhere yet — deliberately, and this is a decision for @LukasWodka
Landing this as advice on purpose, per "arm while green". Requiring the context needs one more thing first: a trigger that gives every PR a status promptly. On a 30-minute sweep a PR opened at minute one would sit at
Expected — waiting for statusuntil the next run — precisely the brickbricked-prs.pyexists to hunt. The per-repopushcaller above is what would make it requireable. No branch protection was touched.Evidence
The input domain is derived, not hand-written
MERGEABLE_STATESandMERGE_STATE_STATUSEScome from GitHub's own GraphQL schema introspection (the command is in the file, reproduce rather than edit). The selftest walks the full 3 × 7 cross product and asserts the verdict for every pair — mutation coverage cannot see a vocabulary gap. The first draft carried aDRAFTmember ofMergeStateStatusthat the schema does not have; deriving it caught that.The load-bearing case is
mergeable=CONFLICTING+mergeStateStatus=UNKNOWN— the shape a conflicted PR presents moments after its base moves, and the one an "any UNKNOWN wins" rule silently drops while passing everything else. It has its own mutation.Mutation-proven
All 17 mutations call the real
scripts/conflict-gate.pyand re-run the real suite — no inline copy of the rule. Two mutations initially reportedUNCAUGHT(harness broke, not detected): they emptied a list the suite then indexed, so it died with anIndexErrorinstead of reporting aFAIL. The harness correctly refused to score a traceback as coverage; fixed incaefcafand both are now genuinely caught.Fail-closed throughout: an unreadable PR list, a PR list at the truncation cap, a status that failed to write, a missing head sha, and a mergeability GitHub will not state are each an error with exit 2 — never a clean sweep. Each has a mutation.
Closes tracebloc/backend#2637
Note
Medium Risk
Fleet-wide GitHub status writes with a scoped app token affect every open PR’s check rollup; misclassification or token scope mistakes could show false reds or fail silently on private repos, though the change is not yet a required merge gate.
Overview
Adds an org-wide conflict gate because merge-conflicted PRs dispatch no
pull_requestworkflows—so drift and quality checks never run and the rollup can look green (including stale successes on an unchanged head sha).scripts/conflict-gate.pysweeps open PRs fromrepo-inventory.yml, classifies mergeability (mergeable/mergeStateStatus), and writes an external commit statusconflict-gate / mergeableon each head: failure when conflicted, success when clear, pending when GitHub cannot answer. It dedupes writes via the REST combined-status endpoint (narrow token:pull-requests: read,statuses: write), retries lazyUNKNOWNmergeability, and fails closed on unreadable lists or failed writes..github/workflows/conflict-gate.ymlruns that sweep on a 30-minute schedule andworkflow_dispatchfromtracebloc/.github, minting the release-train app token—not onpull_request, which cannot fire on conflicted PRs.Makefile wires
selftest-conflict-gateandmutation-conflict-gateinto the existing selftest/mutation tiers. Hermetic selftests plus mutations cover classification, workflow triggers, token scopes, and status dedup. The context is visibility only until branch protection requires it.Reviewed by Cursor Bugbot for commit b04f603. Bugbot is set up for automated code reviews on this repo. Configure here.