Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claude claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_merge true
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-30 1386
...whose head branch is still on the remote 1
native leak rate 0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucket count share reaped?
MERGED 111 33.1% ✅ would delete
NO PR at all 170 50.7% unreachable by construction
CLOSED, unmerged 43 12.8% ⛔ excluded — MERGED-only default
OPEN 11 3.3% ⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling  check:node-version  check:pnpm-acquisition
check:pnpm-filter-targets  check:required-contexts  check:shard-attestation
check:stall-guard-budget  check:workflow-status-functions
check-aggregator-roster  check-self-test-wired  check-self-test-workflow-commands
check-shard-attestation  check-step-collectors  check-whole-set-label-write
check-required-contexts  docs-audit/check-drift-comment  pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.

The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.

Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.

Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@os-project-manager
os-project-manager marked this pull request as ready for review August 30, 2026 15:44
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 4a24b96 Aug 30, 2026
33 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-12771-merged-branch-reaper branch August 30, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants