You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 45ca050
Browse filesBrowse the repository at this point in the historyBrowse files
ci(reaper): guard the MERGED bucket on base.ref === 'main', with its contract harness
`merged_at` says a pull request landed; it does not say WHERE. A stacked PR
merged into another dev branch reports `merged_at` exactly like one that landed
on `main`, and its content reaches `main` only if that base branch itself later
merged -- a second hop the reaper's deliberately flat criterion cannot see.
Measured in the #13503 census (2026-09-02) over 678 `copilot/*` branches: of 575
MERGED, 48 merged into something other than `main`; 43 of those have a base that
itself later merged, 4 have a base that no longer exists, and 1 --
`copilot/check-action-run-status`, based on a PR that closed WITHOUT merging --
has no confirmed path to `main` at all. Unguarded, the classifier calls that one
safe to delete.
`reapable` now additionally requires that one of the branch's merged pull
requests reports `base.ref === 'main'`. Everything else that merged lands in a
new `mergedElsewhere` bucket: rendered in full with the base it merged into,
never reaped. A PR record carrying no `base` is counted as NOT main -- the
fail-closed direction, because the cost of guessing here is a deleted branch.
The guard only NARROWS, and that is asserted rather than promised.
`scripts/check-merged-branch-reaper-outcome.mjs` extracts the shipped script
from the YAML with a real parser, runs it under doubles the way
actions/github-script runs it, and holds four invariants over every scenario --
`reaped => the branch has a merged PR based on main`, the held bucket really is
merged-and-not-into-main, the buckets partition the candidates, and the held
count is rendered. Its `--self-test` deletes the guard, inverts it, renames the
base constant, flips the fail-closed direction and unrecords/unrenders the
bucket, and requires the battery to redden for each; it mutates the decisions
the guard sits beside too (open-PR precedence, the grace window, the defensive
head-ref filter, the protected short-circuit), because a guard that cost
something already there would not be a narrowing.
No `is-ancestor` is introduced anywhere: the base ref is read off the PR record,
which is the instrument the workflow header mandates. The grace window still
runs off the newest merge of ALL merged PRs, so the guard cannot shorten it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
lines.push(`**MERGED, but not into \`${BASE_REF}\` (${n(buckets.mergedElsewhere)})** — the base-ref guard. \`merged_at\` says it landed, the base says not here; the content reaches \`${BASE_REF}\` only if the base branch itself merged, and that second hop is not a question this criterion can answer. Held for a human look:`);
350
+
lines.push(n(buckets.mergedElsewhere)
351
+
? sample(buckets.mergedElsewhere, (r) => `- \`${r.branch}\` — PR #${r.pr} merged into \`${r.base}\` at ${r.merged_at}`, 40)
352
+
: '_none_');
353
+
lines.push('');
267
354
lines.push(`**CLOSED, unmerged (${n(buckets.closedUnmerged)})** — work that never landed; MERGED-only is the default policy:`);
core.notice(`Dry run: ${n(buckets.reapable)} of ${total} ${PREFIX} branches would be deleted. ${n(buckets.noPr)} have no PR and are unreachable by this criterion. Nothing was deleted.`);
368
+
core.notice(`Dry run: ${n(buckets.reapable)} of ${total} ${PREFIX} branches would be deleted. ${n(buckets.mergedElsewhere)} merged somewhere other than \`${BASE_REF}\` and are held by the base-ref guard. ${n(buckets.noPr)} have no PR and are unreachable by this criterion. Nothing was deleted.`);
0 commit comments