Skip to content

ci: merge only after the required checks have actually reported (#1966) - #1970

Merged
justinchuby merged 3 commits into
mainfrom
squad/leon-merge-gate
Aug 24, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/leon-merge-gate

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

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 --auto means "merge as soon as the mergeable state allows", not "merge when the required checks are green". For an actor covered by the merge rules bypass (RepositoryRole id 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:

PR armed required contexts at arm time outcome
#1958 08:05 not yet created on 05742109f merged 08:05:37 — four minutes before its own Fast even started, and it then failed
#1942 ~07:09 created, pending merged 07:25:25, after both reported pass

The 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 --auto armed inside that window merges immediately.

I nearly shipped this myself today. I rebased #1957 with --auto still armed. A force-push resets the head SHA and the old contexts do not carry over — the armed auto-merge does. For as long as babc43018 had no contexts, that PR was one scheduler tick from merging with zero CI. It did not. That was luck. I have since disarmed --auto everywhere and merged #1957 and #1967 through this script instead.

What the script establishes

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 this repo has already shipped in another costume:

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: 0 merged, 2 a required check failed, 3 timed out (pending or never appeared), 4 head moved, 5 required set undeterminable, 6 not about checks (closed/draft/refused).

There is no --admin flag and no code path that could add one; --admin from the caller is rejected as an unknown option.

Evidence

  • scripts/merge_when_green_test.sh — 27/27, hermetic (gh shimmed, backed by fixtures, recording its own argv so "never --admin" is asserted behaviourally rather than by grepping the source).
  • Mutants killed 5/5:
mutant cells that went red
absent counts as success 3
head-move check removed 2
empty required set proceeds 3
pending counts as pass 3
required set hardcoded instead of read from the API 4

Wiring

Added to Rust quality beside 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.

`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

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.02%. Comparing base (b3a9f6c) to head (c594d3b).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.01% <ø> (?)
mlas 85.33% <ø> (?)
offline 81.18% <ø> (+0.93%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 69 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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>
@justinchuby

Copy link
Copy Markdown
Owner Author

Opus review found two ways this script could merge when it should not — the exact defect it exists to eliminate. Both fixed in 1b9dc763c, both now covered.

1. Greenness was inferred from the absence of non-green rows. The decision was [ -z "$notgreen" ], which an empty verdict satisfies. 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 was present but all-whitespace reached the same place by a different route.

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: FAILURE then SUCCESS → merged; the reverse → refused. Now folded pessimistically — a name is green only if every entry carrying it is.

Also taken: ERROR and STALE join the fast-fail set (neither will ever go green, so timing out reports the wrong exit code); and five consecutive unreadable polls is now an environment fault (exit 1) rather than a slow check (exit 3), so nobody retries a revoked token as if it were a queue.

Cells 27 → 38. New: 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 — and I am reporting the two survivors rather than the ratio that flatters.

mutant result
absent counts as success KILLED (4 RED)
head-move check removed KILLED (2 RED)
empty required set proceeds KILLED (5 RED)
required set hardcoded KILLED (5 RED)
rollup parse failure ignored KILLED (15 RED)
duplicates keyed last-wins KILLED (2 RED)
pending counts as pass SURVIVED
greenness inferred, not counted SURVIVED

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:

  • Scoping the required set to rulesets whose ref_name matches the base branch. Today both active branch rulesets target ~DEFAULT_BRANCH and main carries zero checks, so the union is exactly the two required contexts. Over-collecting is the safe direction for merge safety and only costs liveness; under-collecting is the direction that merges something. If a release/* ruleset with its own checks ever appears, this waits forever and exits 3 — loudly, and not by merging.
  • The offline anti-vacuity cell pins source text rather than behaviour. True, and it is the weakest cell here; a behaviour-preserving refactor would turn it red. It is what remains testable with no network, the live probe (MWG_LIVE=1) covers the real API, and a cell that fails loudly for the wrong reason is a cost I will take over one that passes quietly for the wrong reason.

Reviewer also confirmed no gh failure mode produces a false green (auth/404/5xx/rate-limit each land on exit 5, 1 or 3), that a missing python3 or a malformed blob exits before the merge path, and that the CI step has no network, gh, git or pre-existing-target/ dependency.

…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>
@justinchuby

Copy link
Copy Markdown
Owner Author

Re-review found the fix commit's own test was vacuous. Fixed in c594d3b97, and I am correcting a claim I made in the last commit message.

The finding. 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, and mutating its exit 1 to exit 77 changed nothing.

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:

  • the pre-loop failure, renamed to say what it actually tests;
  • a read that breaks mid-wait → exit 1;
  • four dropped reads then a recovery must still merge — otherwise the counter has quietly turned every transient 502 into a refusal, which is a different way to be wrong.

Both mutants now die: retry counter removed and threshold raised beyond any run, each 1 RED on the mid-wait cell. Neither was detectable before.

Correcting my own commit message. I claimed rollup parse failure ignored was killed with 15 RED. That is wrong, and the reviewer's version is right. My mutant inverted the guard so it fired always, which naturally reddens everything; removing it changes no test outcome, because every parser fault raises before printing a row 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 — not on coverage.

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 THIS head property, unplanned. The waiter was running against 1b9dc763c when I pushed the fix:

-- 11:57:33 --
  Fast (Linux x86_64)          COMPLETED    SUCCESS
  Rust quality                 QUEUED       -
merge_when_green.sh: head moved 1b9dc763c -> c594d3b97 while waiting.
merge_when_green.sh: the checks observed so far were for a commit that is no longer
merge_when_green.sh: the tip. Refusing to merge; re-run against the new head.

Fast (Linux x86_64) was green — for a commit that no longer existed at the tip. Exit 4, no merge. That is precisely the #1957 near-miss this PR was written after, reproduced by accident against the real API, and refused.

Reviewer also confirmed the two previously disclosed survivors are genuinely redundant rather than a rationalisation: given the rows == want gate, green == want and [ -z "$notgreen" ] are logically equivalent, so no reachable input separates them.

@justinchuby
justinchuby merged commit 5cc3d87 into main Aug 24, 2026
16 of 17 checks passed
@justinchuby
justinchuby deleted the squad/leon-merge-gate branch August 24, 2026 12:38
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.

1 participant