Skip to content

fix(ci): select mutation fixtures from the divergence point, not the merged base tip - #1590

Merged
davidfarah2003 merged 2 commits into
mainfrom
fix/1582-reproof-divergence-base
Sep 14, 2026
Merged

davidfarah2003 merged 2 commits into
mainfrom
fix/1582-reproof-divergence-base

Conversation

@davidfarah2003

@davidfarah2003 davidfarah2003 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

What was happening

A pull request is checked out as refs/pull/N/merge, so the commit CI works on is a merge commit whose first parent is the base branch tip. The head therefore already contains the base branch. The re-proof selector diffed <base>...HEAD, and once the head contains the base tip, every commit the base branch gained after the fork lands on the included side of that walk and is read as part of the change.

The three-dot diff cannot help here. Three-dot means "from the merge base", and the merge base of a base-branch commit and a head that already contains the base tip is the fork point, which is exactly the commit that makes the base branch's later work look like the pull request's.

So a branch that had fallen behind selected fixtures guarding commits it did not contain, spent runner hours re-proving them, and could be reddened by a fixture it had no relationship to.

Measured on a real run: a four-file change read 30 changed paths. The shard that ran printed 2 fixture(s) selected from 427 (diff 30 record(s), 30 changed path(s), corpus 427), then spent 146 minutes re-proving fixtures guarding commits the branch merely lacked before hitting the step cap, producing no fact about the files the pull request actually changed. Across all twelve shards that selection is 21 fixtures.

Why the obvious fix does not work

Normalising the event's base with git merge-base "$BASE" HEAD looks right and changes nothing. That job was already handed the merge base, so the expression returns its own input and the selection does not move. The same is true of merge-base HEAD^1 HEAD^2: under a merge ref it re-derives the very base that was passed in.

Every formula that re-derives the base from the merge head finds the base it was already given. When the head is a merge, the first parent is the base branch tip the head was built on, and that is the commit to diff from. Checked against the API's own file list for the two affected pull requests: first-parent gives 4 and 22 files, and the API reports 4 and 22.

What now happens

  • Both selector sites resolve the base through one shared script, scripts/mutation-reproof-base.sh. The base expression previously appeared twice, and two copies of the selector is the shape of this defect: a fix applied to one site would leave the plan and the shard disagreeing about which fixtures exist, with a green board either way.
  • When a pull request's head is a merge, the base is its first parent. The rule is gated on the event, because a merge head is not exclusive to pull requests: a push that lands several commits and ends on a merge would otherwise discard github.event.before and diff only the merge itself, silently dropping the rest of the pushed range. That shape is reachable rather than hypothetical: merge commits are enabled on this repository, main has taken 30 of them since 2026-09-06, and 21 of the last 100 push-triggered runs of this workflow ran on a two-parent head. An unrecognised or missing event is refused rather than guessed at.
  • Otherwise the base is normalised with merge-base, which is a no-op when it is already the divergence point. Push, schedule, first-push (all-zero sha) and empty-base shapes keep their existing meaning and each has its own cell.
  • The resolved base sha is printed beside the record count. The counts alone could not explain themselves; naming the base makes a selection auditable from a transcript without a clone.
  • A base that cannot be resolved fails loudly. A shallow checkout answers exactly as a repository with no common ancestor does, and diffing against nothing would select zero fixtures and report a gate that proved nothing. All three checkouts in this workflow use fetch-depth: 0, and this refusal is what catches a future change that drops it.

On the measured run, the whole-corpus selection goes from 21 fixtures across 30 paths to 2 fixtures across 4 paths, and the shard that ran for 146 minutes is not fanned at all.

Proof

  • Cells for each behaviour: a branch behind its base selects only its own diff; a branch level with its base selects the same set as before (refuse control, and it must agree exactly); a push landing several commits onto a merge head keeps its whole pushed range, while the same head under pull_request takes its first parent; pull_request_target is refused, because that event checks out the base and its merge head is not the pull request's; an unknown or missing event is refused; the resolved base appears in the transcript; an unresolvable base fails loud. Each was shown red with the fix reverted.
  • bin/smoke/mutations/mutation-reproof-base.json: 11 mutants, each reddening its cell by name. Mutants revert each workflow site independently, take the second parent instead of the first, apply the first-parent rule on every event, guess at an unrecognised event, accept an empty merge base, drop the printed base, remove the all-zero fallback, and re-admit pull_request_target. All 11 were run against this head and all 11 were killed; that sweep is a local run, as the mutation proof is not part of this pull request's CI board.
  • CI on this head: the re-proof smoke reports mutation-reproof smoke: 143 passed, 0 failed, alongside the full typecheck, the fixture-anchor gate, the corpus partition smoke, the full-sweep workflow smoke and the concurrency smoke.

Not claimed

This does not change the selection on the other pull request where a fixture read WRONG-RED. Replaying that shard both ways selects the same four fixtures, because the fixture's guarded source is in that branch's own diff. That verdict is a real signal about that branch and this change neither creates nor clears it.

Refs #1582

…merged base tip

A pull request is checked out as `refs/pull/N/merge`, so HEAD is a merge
commit whose FIRST parent is the base branch tip. HEAD therefore already
contains that tip, and the selector's `<base>...HEAD` walk reported every
commit the base branch gained after the fork as though the head had made
it. The three-dot diff cannot help: once HEAD contains the base tip, the
merge base is the fork point and main's later commits sit on the included
side.

PR #1520 read 30 changed paths for a four-file change. The shard that ran
printed `2 fixture(s) selected from 427 (diff 30 record(s), 30 changed
path(s), corpus 427)` and spent 146 minutes re-proving fixtures guarding
commits the branch merely lacked, producing no fact about its own files.
Across all twelve shards that selection was 21 fixtures; under the fix it
is 2, and the shard that timed out is not fanned at all.

Normalising the event's base with `git merge-base "$BASE" HEAD` does not
fix this. That job was already handed the merge base, so the expression
returns its own input and the selection does not move. When HEAD is a
merge the first parent IS the base, and that matches GitHub's own file
list exactly: 4 files on #1520 and 22 on #1524.

The first-parent rule is gated on the event, because a merge head is not
exclusive to pull requests. A push that lands several commits and ends on
a merge would otherwise discard `github.event.before` and diff only the
merge: replaying real push heads, an ungated rule loses `glama.json` at
934f2ca and four more paths at d956e57, all direct-to-main commits no
pull request run ever proved. An unrecognised event is refused rather
than guessed at.

Both selector sites resolve through one script rather than two copies of
the logic, since a site that resolved differently would re-prove a set
the plan never fanned it for. The resolved base sha is printed beside the
record count, because the counts alone could not explain themselves. An
unresolvable base fails loud: a shallow checkout answers exactly as a
repository with no common ancestor does, and diffing against nothing
would select zero fixtures and report a gate that proved nothing.

Refs #1582
…nnot know

Review found the event gate named `pull_request_target` beside `pull_request`
with no cell asserting that arm. Checking the arm rather than adding a cell for
it showed the arm should not exist: its premise is false.

GitHub documents `pull_request_target` as running with GITHUB_SHA at the last
commit on the DEFAULT BRANCH, "rather than in the context of the merge commit,
as the pull_request event does", and it carries a `pull_request` payload, which
has no `before`, so the workflow's BASE expression hands the resolver an empty
string. The pull request's head is not in that checkout at all.

Measured on a repository whose default branch tip is a merge, which is this one:
`allow_merge_commit` is on and main carries 28 merge commits since 2026-09-06.
The checkout therefore does have a second parent, but it belongs to somebody
else's merged pull request, so the first-parent rule resolved to
main-before-that-unrelated-merge and selected THAT pull request's fixtures,
exiting 0 and printing nothing unusual. Deleting only the arm is not a fix
either: the empty-base fallback takes `HEAD~1`, which on a merge is the same
commit, so it reaches the identical wrong answer with only a stderr note. Both
spellings are silently wrong, which is exactly the defect this branch exists to
remove, so the event is now refused and must be decided by whoever adds the
trigger. The workflow has no such trigger today.

The arm surviving its mutant was the symptom of this, not a coverage gap to be
papered over with a cell: the arm was a no-op under the shipped BASE expression,
so any cell written to the old premise would have passed with and without it.
The cell now asserts the refusal, and the mutant that pins it re-admits the
event to the allow-list, which is where the behaviour now lives.

Also corrects the mutant retargeted in the previous revision, whose `expectRed`
named a different cell and so graded WRONG-RED against a suite that had in fact
gone red.

Refs #1582
@davidfarah2003
davidfarah2003 force-pushed the fix/1582-reproof-divergence-base branch from cdd0d96 to 5a6aba5 Compare September 14, 2026 01:54
@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Corrections to the description, which is stale at the current head. The description is left as posted because CI is running against it; these supersede it.

Two figures are stale and are now wrong.

  • It says the mutation fixture carries 10 mutants. It carries 11. The eleventh pins the pull_request_target refusal described below.
  • It says the re-proof smoke has 138 cells. It has 143, measured unmutated at the current head: 143 passed, 0 failed.

One claim is withdrawn rather than re-counted. The description replayed two real push heads and named the paths an ungated first-parent rule would lose. That method is not sound: it assumed consecutive workflow-run head shas bound a single push, and they do not, because a commit marked [skip ci] produces no run at all. How much an ungated rule discards depends on github.event.before, which is not recoverable from a past run. No count replaces it. The push cell constructs the shape and asserts the behaviour instead, which is checkable.

A behaviour changed since the description was written. pull_request_target was originally treated as a pull request, on the premise that it checks out a merge ref the same way. That premise is false: the documented behaviour is GITHUB_SHA at the last commit on the default branch, "rather than in the context of the merge commit, as the pull_request event does", with a pull_request payload that carries no before. The pull request's head is not in that checkout at all.

On a repository whose default branch tip is a merge, which is the case here, that checkout does have a second parent, but it belongs to another merged pull request. Measured both ways with an empty base: keeping the first-parent rule and deleting it both resolve to the same commit and select that other pull request's fixtures, exiting 0. Neither spelling can find the right base, so the event is now refused with a non-zero exit and an UNMEASURED message, and whoever adds such a trigger decides what base it means. The workflow has no such trigger today.

Not stale, and re-derived first-hand after being questioned: the 21 fixtures figure. The cited job printed 2, which is one shard's selection. Summing the eleven shards that ran in that run gives 2+2+1+2+3+2+1+1+2+4+1 = 21. Both numbers are correct and the description already distinguishes them.

Acceptance, from this pull request's own run at the current head. The plan job printed 5 fixture(s) selected from 433 (base 840e64110d28e8f487434de24576d6dcc468ee18, diff 6 record(s), 6 changed path(s), corpus 433). Those 6 paths equal this pull request's own file list exactly. Locally: 11 of 11 mutants killed, proof exit 0, tree clean before and after.

Refs #1582

@davidfarah2003
davidfarah2003 merged commit e700ce2 into main Sep 14, 2026
32 checks passed
@davidfarah2003

Copy link
Copy Markdown
Contributor Author

The description has now been corrected in place, so the note above is no longer a supersede: the two stale figures are 11 mutants and 143 passed, 0 failed in the description itself, and the withdrawn push path-loss counts are gone from it rather than merely corrected here.

The reachability of the ungated-rule shape now rests only on facts that can be rechecked: merge commits are enabled on this repository, main has taken 30 of them since 2026-09-06, and 21 of the last 100 push-triggered runs of this workflow ran on a two-parent head. No count is claimed for how much an ungated rule would discard, because that depends on github.event.before and is not recoverable after the fact.

The head is unchanged at 5a6aba5c862e27d54e51106c9c3b6248ad60965a and no commit was pushed for this edit, so the board above still refers to the code under review.

Refs #1582

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