Uh oh!
There was an error while loading. Please reload this page.
ci(gate): fail a PR that declares itself blocked (backend#1729) - #229
Conversation
data-ingestors#468 was titled "... (D10) [blocked on S2]". It collected three
approvals and was merged by its author 105 seconds after S2 merged. The author
waited for the blocker they had written down -- but "S2 merged" was never the
real precondition, and nothing could tell the difference. dev and staging
ingestion broke within hours and stayed broken for a day (backend#1752).
The dependency was recorded as prose, in a place nothing reads. This makes that
sentence a merge gate: a PR whose title or labels say it is blocked fails a
check. Two open PRs carry the shape today -- client#490 ("HOLD until v0.8.0
image") and client-runtime#192 ("DO NOT MERGE").
What it does not do: decide whether the blocker is genuinely resolved. #468's
author satisfied the sentence they wrote and the real precondition was still
unmet. The claim is only that clearing the marker is a deliberate act.
The matching is the entire risk surface, so it lives in a tested script rather
than a YAML heredoc. Precision over recall: `unblocked`, `unblocks`, `blocker`,
`threshold`, `holder` and `wipe` must not fire, or the gate gets called noise
and switched off -- the failure mode house-rules.sh names in its own notes.
No callers in this PR, deliberately. The reusable lands first so the matching
can be reviewed alone; repo-inventory.yml records it `exempt` everywhere with
the rollout named, because this file's own header forbids adding a caller and
flipping it to `required` in one PR. Arming is a briefing decision: two live
PRs go red the moment it is on, which is the gate working.
Verified: marker selftest 22 checks green, including the GITHUB_EVENT_PATH path
the workflow actually uses; both event-path assertions mutation-checked (drop
the title / drop the labels -> each fails). caller-drift selftest 116/0.
load_inventory + check_source_reusables pass on the edited inventory, and fail
as intended when the reusables row is removed. actionlint clean.…itive Ran the matcher over 588 merged PR titles from ten tracebloc repos rather than trusting the table I had imagined. It produced exactly one false positive: chore(ci): retire the WIP-limit nudge and it is the worst kind, because "WIP limit" is a domain term in THIS org -- wip-limit-check.yml is one of our own reusables, so the phrase recurs. The (?<![a-z])/(?![a-z]) guards do not help: the character after "WIP" is a hyphen. That is precisely the failure this gate cannot afford. A check that fires on a normal chore PR gets called noise and switched off, and then catches nothing ever again. WIP-as-a-marker is a PREFIX convention (`WIP: ...`, `[WIP] ...`); WIP mid-title is the noun. Anchoring to the start keeps every real marker and drops the false positive. Over the same 588 titles the matcher now blocks exactly one: data-ingestors#468 -- the PR this gate exists for. Both new cases mutation-checked: un-anchoring `wip` fails 2 assertions.
LukasWodka
commented
Aug 12, 2026
Measured the false-positive rate instead of asserting it — and it found oneI claimed "precision over recall" in the description, so I went and checked it against reality rather than against the table I had imagined. Ran the matcher over 588 merged PR titles from ten tracebloc repos ( It found a real false positive, and the bad kind: "WIP limit" is a domain term in this org — That is exactly the failure this gate cannot afford: fire once on a normal chore PR, get called noise, get switched off, catch nothing ever again. Fix (571d8c0):
The one remaining hit is Both new assertions mutation-checked: un-anchoring Also verified against the live PRs, by API rather than by transcription
Note |
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 12, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…imal perms; label events (Bugbot #229) - blocked-marker.py: a GITHUB_EVENT_PATH payload with no pull_request object now refuses (exit 2) instead of returning an empty title -> 'not blocked' -> exit 0. A non-PR or malformed event was a silent fail-OPEN; it now fails closed like the no-event-path branch. Selftest case added. - blocked-gate.yml: drop 'pull-requests: read' — the script reads GITHUB_EVENT_PATH, never the API, so the wider grant would exceed a minimal contents:read caller and fail the reusable at startup. - Document that callers must trigger on labeled/unlabeled too, or the label half of the gate never re-runs until the next synchronize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LukasWodka
commented
Aug 12, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 36edecf. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
Summary
data-ingestors#468was titled:It collected three approvals and was merged by its author 105 seconds after S2 merged. The author did wait for the blocker they had written down — but "S2 merged" was never the real precondition (
SERVICE_DB_ACCOUNTSon fleet-wide was), and nothing anywhere could tell the difference. dev and staging ingestion broke within hours and stayed broken for a day (backend#1752).The dependency was recorded as prose, in a place nothing reads. This PR makes that sentence a merge gate: a PR whose title or labels say it is blocked fails a check.
Two open PRs carry the same shape right now:
client#490chore(chart): … — HOLD until v0.8.0 imageclient-runtime#192DO NOT MERGE: fix(training): add configurable activeDeadlineSecondsWhat it does not do
It cannot tell whether the blocker is genuinely resolved — the #468 author satisfied the sentence they wrote and the real condition was still unmet. The only claim is that clearing the marker is a deliberate act by someone who has looked. This is not "PRs are never merged early".
Design notes
scripts/blocked-marker.py), not a YAML heredoc — the same lesson as backend#1746 from the other direction.house-rules.shnames that failure mode in its own design notes. The(?<![a-z])lookbehinds are what killunblocked,unblocks,blocker,threshold,holder,wipeandswipe.blockedlabel is the zero-false-positive half; the title markers catch the unlabelled case — which is exactly what #468 was.fr-gatehasskip-fr-gatebecause satisfying it can take a release cycle. Here the fix is editing your own title, so an escape hatch would only ever be used to skip the thinking.${{ github.event.pull_request.title }}appears anywhere — the script readsGITHUB_EVENT_PATHitself.editedis in the caller trigger list on purpose: the gate reads the title, so a title edited to add or remove a marker must re-evaluate, or "I removed the marker" would never turn the check green.No callers in this PR — on purpose
The reusable and its selftest land first, so the matching can be reviewed on its own.
repo-inventory.ymlrecords itexemptfor all 20 repos with the rollout named, because that file's own header forbids adding a caller and flipping its entry torequiredin one PR: the.githubcaller state is read from its audit branch over the API, so the caller would not be there yet.The exemption text says STAGED, NOT PARKED in so many words, and says that if the anchor is still present after the rollout PR, it has become the same finding as
wip_limit_check_has_no_callersandversion_bump_pr_never_wired— a reusable that shipped, was never wired up, and had a written excuse for it.Arming is a briefing decision, not a silent flip: the two PRs in the table above go red the moment it is on. That is the gate working as intended, but it should not arrive as a surprise.
devex-bootstrapgets its own exemption rather than the shared anchor — it has no.githubdirectory at all, so unlike the other 19 it does not clear when the rollout lands.Test plan
scripts/tests/blocked-marker-selftest.pyclient#490,client-runtime#192, copied verbatimunblocked,unblocks,blocker,blocking,threshold,holder,household,wipe,swipeGITHUB_EVENT_PATHpath the workflow actually useseditedpayload reads the new title, notchanges.from_from_event/ drop the labelsscripts/tests/caller-drift-selftest.pyload_inventory+check_source_reusableson the edited inventoryreusablesrow is removedactionlintAll of the above re-run after rebasing onto
develop, which carries the newercaller-drift.pyfrom #222 and #223.Ref: backend#1729, backend#1752,
data-ingestors#468.🤖 Generated with Claude Code
Note
Medium Risk
Introduces org-wide merge-gate matching logic that will block PRs once rolled out; false positives could force the gate off. Callers are deliberately not wired in this PR, so merge behavior is unchanged until the follow-up.
Overview
Adds a blocked merge gate so a PR that announces its own blocker (title markers like
[blocked on X],DO NOT MERGE,HOLD until,WIP:, or the orgblockedlabel) fails a check. Motivated bydata-ingestors#468, which merged with three approvals while still blocked in prose.Matching is the whole risk surface, so it lives in tested
scripts/blocked-marker.pyrather than YAML. Patterns favor precision over recall (lookbehinds reject false friends likeunblocked,blocker,threshold,wipe). The reusable never interpolates the PR title into the shell — it readsGITHUB_EVENT_PATH. No override label by design.No callers in this PR.
blocked-gate.ymland its selftest land first for review;repo-inventory.ymllists the reusable asexempt: *blocked_gate_rollout_pendingacross repos (with a separate exemption fordevex-bootstrap). Callers and arming follow in the rollout PR.Reviewed by Cursor Bugbot for commit 36edecf. Bugbot is set up for automated code reviews on this repo. Configure here.