Repository navigation
ci: merge only after the required checks have actually reported (#1966) - #1970
Conversation
`gh pr merge --auto` does not mean "merge when the required checks are green". It means "merge as soon as the mergeable state allows", and for an actor covered by a ruleset bypass -- this repository's `merge rules` has one bypass actor, `RepositoryRole` id 5 (admin), `bypass_mode: always` -- the required contexts are advisory, so the mergeable state allows it as soon as there is nothing visibly wrong. The trap is that "nothing visibly wrong" includes "the check has not been created yet". Under runner saturation a context takes 15-60 minutes to appear. #1958 was armed at 08:05 and merged at 08:05:37; its `Fast (Linux x86_64)` did not start until 08:09:40, and failed. Nobody typed `--admin`. Armed twenty minutes later the same command waits correctly, which is worse than a consistently broken gate because it teaches you to trust it. I nearly shipped the same thing today: I rebased #1957 with `--auto` still armed. A force-push resets the head SHA and the old contexts do not carry over, but the armed auto-merge does, so for as long as the new head had no contexts that PR was one scheduler tick from merging unchecked. It did not. That was luck, not process. `scripts/merge_when_green.sh <pr>` establishes the property `--auto` does not: every context the RULESET names as required is PRESENT in the rollup, for THIS head commit, and every one concluded SUCCESS. Each of those three words is a defect we have already shipped: PRESENT absent is not pending and pending is not pass. A poller that greps for failure reports success for a check that does not exist -- the vacuous-guard class (#1817) relocated into the gate that guards everything else. THIS the head is pinned; if it moves, the wait is void (exit 4). RULESET the required set is read from the API. A hardcoded list drifts silently into a shorter one, and the failure mode of that is a green report. It fails closed: an undetermined required set is exit 5, never a merge. Refusals have distinct exit codes because "did not merge" is not one outcome -- a caller that cannot tell timeout from failure retries the wrong ones. There is no `--admin` flag and no code path that could add one; `--admin` from the caller is rejected as unknown. 27 hermetic cells (`gh` is shimmed, and records its own argv, so "never --admin" is behavioural rather than a grep of the source). Mutants killed 5/5: absent-counts-as-success, head-move-check-removed, empty-required-set-proceeds, pending-counts-as-pass, and required-set- hardcoded. shellcheck clean. Wired into `Rust quality` beside the other self-tests, because the failure mode of this particular script is a green report. This does not change repository configuration, which is not mine to change; #1966 tracks that decision. It makes the safe invocation the easy one in the meantime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1970 +/- ##
==========================================
+ Coverage 80.25% 81.02% +0.77%
==========================================
Files 407 424 +17
Lines 188524 207556 +19032
Branches 188524 207556 +19032
==========================================
+ Hits 151293 168179 +16886
- Misses 31822 33741 +1919
- Partials 5409 5636 +227
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Opus review found two ways `merge_when_green.sh` could merge when it should not. Both are the defect the script exists to eliminate, so both are fixed and both now have cells. 1. Greenness was inferred from the ABSENCE of non-green rows, so a verdict with no rows at all merged. Reproduced end to end: a `statusCheckRollup` containing a non-object entry makes the parser throw, it writes nothing to stdout, and the script printed "every required check reported SUCCESS" and merged. A required set that is present but blank did the same thing by a different route. The parser's exit status is now checked (exit 5), the required set is filtered for non-blank lines before the emptiness test, and the decision counts SUCCESS rows against the required count rather than inferring from an empty complement. 2. Duplicate rollup entries with the same name were keyed last-wins, so for a required context appearing twice the ARRAY ORDER decided whether we merged -- ordering GitHub does not document as chronological, and re-runs produce exactly this shape. Confirmed: FAILURE then SUCCESS merged; SUCCESS then FAILURE refused. Now folded pessimistically: a name is green only if every entry carrying it is. Also from review: `ERROR` and `STALE` join the fast-fail set, since neither will ever go green and waiting out the timeout reports the wrong exit code; and a `gh` read that fails five polls running is now an environment fault (exit 1) rather than a slow check (exit 3), so a caller does not retry a revoked token as if it were a queue. 27 -> 38 cells: non-object rollup entry, whitespace-only required set, duplicate name in both orders, duplicate all-green still merges, legacy `ERROR` fails fast, unreadable PR is exit 1. Mutants: 8 run, 6 killed. Two survivors are reported rather than hidden, because both are redundancy by design and I would rather say so than imply coverage this suite does not have -- `pending counts as pass` and `greenness inferred not counted` each survive because the OTHER guard refuses anyway. Removing the pessimistic fold (2 RED) and ignoring the parser's exit status (15 RED) are both caught. The comment at the decision says which layer is load-bearing and which is belt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Opus review found two ways this script could merge when it should not — the exact defect it exists to eliminate. Both fixed in 1. Greenness was inferred from the absence of non-green rows. The decision was Fixed three ways, because the empty verdict had three sources: the parser's exit status is now checked (exit 5), the required set is filtered for non-blank lines before the emptiness test, and the decision counts SUCCESS rows against the required count instead of inferring from an empty complement. 2. Duplicate rollup entries were keyed last-wins. For a required context appearing twice, the array order decided whether we merged — ordering GitHub does not document as chronological, and a workflow re-run produces exactly this shape. Confirmed against the pre-fix script: Also taken: Cells 27 → 38. New: non-object rollup entry; whitespace-only required set; duplicate name in both orders; duplicate all-green still merges; legacy Mutants: 8 run, 6 killed — and I am reporting the two survivors rather than the ratio that flatters.
Both survivors are redundancy by design: each is covered by the other guard, so breaking one alone changes no outcome. I could have removed the redundant layer to make the table read 8/8, and chose not to — a merge gate is where belt-and-braces is justified — but the comment at the decision now says plainly which layer is load-bearing and which is belt, so nobody later mistakes an uncovered line for a tested one. That distinction is the whole subject of #1817. Not taken, with reasons:
Reviewer also confirmed no |
…ched Opus re-review: the cell named `a pull request that cannot be read at all is an environment fault` did not exercise the code it was named after. The shim failed EVERY `gh pr view`, so the opening snapshot failed and the pre-existing pre-loop `die` produced exit 1. The loop was never entered. Deleting the entire misses block left the suite at 38/38. So the thing the previous commit actually added -- five consecutive in-loop read failures being an environment fault rather than a slow check -- had zero coverage, while a passing cell carried its name. That is the vacuous-guard shape, in the suite for the script whose subject is the vacuous-guard shape. The shim now fails a WINDOW of reads instead of all of them, so a cell can let the opening read succeed and break the connection afterwards. Three cells: the pre-loop failure (renamed to say what it tests), a read that breaks mid-wait (exit 1), and four dropped reads then a recovery, which must still merge -- otherwise the counter has turned every transient 502 into a refusal. 38 -> 41. Mutants: retry counter removed, and threshold raised beyond any run -- both now RED on the mid-wait cell. Neither was detectable before. Correcting the previous commit message: it claimed `rollup parse failure ignored` was killed with 15 RED. That mutant INVERTED the guard so it fired always; removing it, as the reviewer did, changes no test outcome, because every parser fault raises before printing and the `rows != want` count then refuses with the same exit 5. The guard is a third redundant layer, not a uniquely covered one. It earns its place on the diagnostic -- "parser exit N" rather than a row-count mismatch -- and not on coverage. Honest count for this file: 11 mutants run, 8 killed, 3 disclosed survivors, all three redundancy by design. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Re-review found the fix commit's own test was vacuous. Fixed in The finding. The cell named So the behaviour that commit actually added — five consecutive in-loop read failures being an environment fault rather than a slow check — had zero coverage, while a green cell carried its name. That is the vacuous-guard shape, in the suite for the script whose subject is the vacuous-guard shape. Reviewer was right to lead with it. The fix. The shim now fails a window of reads rather than all of them, so a cell can let the opening read succeed and break the connection afterwards. Three cells, 38 → 41:
Both mutants now die: Correcting my own commit message. I claimed Honest count for this file: 11 mutants run, 8 killed, 3 disclosed survivors, all three redundancy by design. I would rather publish that than a ratio that flatters, for the same reason the survivors are in the commit message. Live evidence for the
Reviewer also confirmed the two previously disclosed survivors are genuinely redundant rather than a rationalisation: given the |
Addresses #1966. Does not change repository configuration — that decision is #1966's and not mine to take unilaterally. This makes the safe invocation the easy one in the meantime.
The mechanism, with the missing half
gh pr merge --automeans "merge as soon as the mergeable state allows", not "merge when the required checks are green". For an actor covered by themerge rulesbypass (RepositoryRoleid 5,bypass_mode: always) the required contexts are advisory. But the bypass alone does not explain the observed behaviour — two PRs armed by bypassing actors behaved differently:05742109fFasteven started, and it then failedpendingpassThe bypass does not fire through a pending check. It fires when there is nothing to see. Under saturation a context takes 15–60 minutes to be created, and
--autoarmed inside that window merges immediately.I nearly shipped this myself today. I rebased #1957 with
--autostill armed. A force-push resets the head SHA and the old contexts do not carry over — the armed auto-merge does. For as long asbabc43018had no contexts, that PR was one scheduler tick from merging with zero CI. It did not. That was luck. I have since disarmed--autoeverywhere and merged #1957 and #1967 through this script instead.What the script establishes
Each of those three words is a defect this repo has already shipped in another costume:
gh pr checks | grep -v failreports success for a check that does not exist, because there is no row to grep. That is the vacuous-guard class (Vacuous-guard class: five independent instances of a test that ran, reported ok, and asserted nothing #1817) relocated into the gate that guards everything else.Fails closed: an undetermined required set is exit 5, never a merge, because an empty required set reads identically to a satisfied one.
Distinct exit codes, because "did not merge" is not one outcome and a caller that cannot tell timeout from failure retries the wrong ones:
0merged,2a required check failed,3timed out (pending or never appeared),4head moved,5required set undeterminable,6not about checks (closed/draft/refused).There is no
--adminflag and no code path that could add one;--adminfrom the caller is rejected as an unknown option.Evidence
scripts/merge_when_green_test.sh— 27/27, hermetic (ghshimmed, backed by fixtures, recording its own argv so "never--admin" is asserted behaviourally rather than by grepping the source).shellcheck -xclean on both scripts.b3a9f6cb6) and fix(hostlock): letrun --ownercertify the rows it wraps (#1929) #1967 (8d6377f00) were both merged by this script, after all required contexts reported SUCCESS on the exact head SHA.Wiring
Added to
Rust qualitybeside the existing(self-test)steps. The suite is hermetic and costs about a second. It belongs in a required lane specifically because this script's failure mode is a green report: if it starts treating an absent check as a passing one, nothing downstream notices, because the evidence it was meant to collect is exactly what is missing.The live-API probe is opt-in (
MWG_LIVE=1) so CI has no network dependency; the offline branch asserts the property that makes the fixtures meaningful — that the required set is read from the ruleset API rather than listed in the script. Both branches assert exactly one cell, so the count pin is invariant across environments.