Merged-Branch Reaper #9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Merged-Branch Reaper | |
| # The standing sweep for #12771: `claude/*` branches that outlived the PR they | |
| # were the head of. | |
| # | |
| # ⚠️⚠️ THIS WORKFLOW DELETES BRANCHES. It was ARMED on 2026-09-04 by the | |
| # maintainer's ruling on #12771 (decision batch #30), which reaffirmed the | |
| # ruling of 2026-08-31 once the base-ref guard of #13503 had landed (PR #15144). | |
| # Before that it classified and reported and the deleting mode was absent; the | |
| # history below is kept because every fence it records is still load-bearing. | |
| # | |
| # Exactly what deletes, and what never does: | |
| # | |
| # DELETES the `reapable` bucket, and nothing else: a `claude/*` branch whose | |
| # OWN pull request reports `merged_at` AND `base.ref === 'main'`, | |
| # past the grace window, with no open PR of its own. | |
| # NEVER `mergedElsewhere`, `closedUnmerged`, `noPr`, `open`, `grace`, | |
| # `protectedBranch`. All six stay REPORT-ONLY — listed in the run | |
| # log every sweep, never touched. Widening past `reapable` is a new | |
| # maintainer ruling, ⛔ not an edit to this file. | |
| # NEVER on `pull_request`. The self-exercising run below stays a DRY RUN, | |
| # which is what keeps "edit this workflow" from meaning "delete | |
| # branches". The `reap` job's `if:` excludes it explicitly. | |
| # NEVER on a `workflow_dispatch` unless the operator sets `dry_run: false`. | |
| # That input defaults to TRUE, so the manual path is fail-closed: a | |
| # manual run is for LOOKING at the list unless someone says otherwise. | |
| # | |
| # The deletion list is printed to the run log BEFORE the first deletion. That | |
| # log is the audit trail the ruling asks for. | |
| # | |
| # ## Two jobs, because `permissions:` is scoped per JOB | |
| # | |
| # `sweep` classifies. It runs with `contents: read` + `pull-requests: read` and | |
| # is structurally incapable of deleting a ref — the same token grant it was | |
| # reviewed under, and the one every scenario of the contract harness drives. | |
| # `reap` consumes the `reapable` list `sweep` published as a job output and is | |
| # the ONLY job in this file holding `contents: write`. | |
| # | |
| # The split is the point. Deleting needs a write grant; classifying must not | |
| # have one. A single job would have handed the write token to the ~140 lines | |
| # that decide WHICH branches, where a defect turns into a deleted branch. As | |
| # split, the write token reaches one job that makes no classification decision | |
| # of its own: it re-checks the prefix, prints the list, and calls DELETE. | |
| # | |
| # ## The criterion is PR state MERGED. ⛔⛔ NEVER `is-ancestor`. | |
| # | |
| # A branch is reapable here if, and only if, the API says a pull request whose | |
| # HEAD REF is that branch has a non-null `merged_at`. | |
| # | |
| # The tempting alternative — `git merge-base --is-ancestor <tip> origin/main` — | |
| # is forbidden anywhere in this file, including as a secondary confirmation. | |
| # The reason is measured, not stylistic: this repo squash-merges through a merge | |
| # queue that REWRITES COMMITS (`allow_squash_merge=true`, `allow_merge_commit= | |
| # false`), so a fully-merged branch's tip is normally NOT an ancestor of `main`. | |
| # The filing seat measured the gap directly (#12771): of 304 `claude/*` branches | |
| # then present, the ancestor probe could vouch for just 15 — a FLOOR, not an | |
| # estimate. An ancestor-based reaper would delete those ~15, declare victory, | |
| # and leave the real accumulation untouched. It is the single most likely way to | |
| # ship something here that looks correct and is not. | |
| # | |
| # `is-ancestor` proves "nothing would be lost". It does NOT prove the converse, | |
| # and the converse is what a reaper needs. | |
| # | |
| # ## MERGED is not MERGED INTO `main` — the base-ref guard (#13503) | |
| # | |
| # `merged_at` says a pull request landed. It does not say WHERE. A stacked PR | |
| # whose base is 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 this deliberately flat criterion cannot | |
| # see and must not start guessing at. | |
| # | |
| # Measured, not supposed (the #13503 census, 2026-09-02, over the 678 | |
| # `copilot/*` branches): of 575 MERGED, 48 (8.3%) merged into something other | |
| # than `main`. Walking all 48 — 43 have a base that itself later merged to | |
| # `main`, so the content lands via a 2-hop chain; 4 have a base that no longer | |
| # exists and could not be re-verified; and 1, `copilot/check-action-run-status` | |
| # (based on a PR that closed WITHOUT merging), is a MERGED branch whose content | |
| # has no confirmed path to `main` at all. Against that last one the unguarded | |
| # criterion says "safe to delete", and it is wrong. | |
| # | |
| # So `reapable` now additionally requires that at least one of the branch's | |
| # merged PRs reports `base.ref === 'main'`. Everything else that merged goes to | |
| # `mergedElsewhere`: reported in full, never reaped — the 43 safe-by-chain ones | |
| # included. That asymmetry is the point. A reaper's two error directions do not | |
| # cost the same: an unreaped branch costs a line of noise in `ls-remote`, a | |
| # wrongly reaped one costs work that exists nowhere else. The guard buys the | |
| # removal of the one false positive with a handful of false negatives, at a | |
| # measured rate of 48 held per 575 merged. | |
| # | |
| # ⛔ THE GUARD ONLY NARROWS, and that is asserted rather than asked for on | |
| # trust. `scripts/check-merged-branch-reaper-outcome.mjs` extracts THIS script | |
| # from THIS file and drives it under doubles, holding the invariant | |
| # `reapable ⇒ that branch has a merged PR whose base is main` over every | |
| # scenario — so no later edit here can widen what is reaped without turning | |
| # that check red, and its `--self-test` deletes the guard and requires the | |
| # battery to notice. | |
| # | |
| # ⛔ It is NOT `is-ancestor` wearing a new hat. The base ref is read off the PR | |
| # RECORD, which is the instrument the section above mandates; nothing here asks | |
| # git whether a commit is reachable from anything. | |
| # | |
| # ## Why a scheduled sweep and NOT `on: pull_request` — measured, 2026-08-30 | |
| # | |
| # The obvious shape is an event-driven reaper on `pull_request: [closed]`. It | |
| # was measured before being written, and the measurement says do not build it: | |
| # | |
| # repo setting `delete_branch_on_merge` = TRUE (already on) | |
| # `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% | |
| # | |
| # GitHub's own `delete_branch_on_merge` already reaps 1385 of every 1386. An | |
| # `on: pull_request` reaper would fire ~140 times a day to find nothing ~99.93% | |
| # of the time, and would be a second writer racing the platform setting on the | |
| # same ref. What is actually left for a workflow to do is the part the native | |
| # setting cannot reach: | |
| # | |
| # - the LEGACY BACKLOG — branches merged before the setting was enabled. 111 | |
| # of them, and they are old: 62 merged in 2026-04, 33 in 2026-06, only 8 in | |
| # 2026-08 (newest 2026-08-20). This is a one-time debt, not a flow. | |
| # - the RARE LEAK — that 1-in-1386 the setting misses. A periodic sweep | |
| # catches it a few days later at no extra cost. | |
| # | |
| # One mechanism covers both cleanly, so this is one mechanism: a weekly sweep | |
| # plus `workflow_dispatch`. Weekly is calibrated to the leak rate, not guessed — | |
| # at ~1 escaped branch per 10 days there is nothing for a daily run to find. | |
| # | |
| # ## ⚠️ What this reaper does NOT reach, stated because the gap is the point | |
| # | |
| # The MERGED criterion can only ever touch a branch that HAS a PR. Measured over | |
| # all 335 `claude/*` branches on 2026-08-30, classified by PR state: | |
| # | |
| # MERGED 111 (33.1%) <- everything this workflow can ever reap | |
| # NO PR AT ALL 170 (50.7%) <- ⚠️ unreachable here BY CONSTRUCTION | |
| # CLOSED, unmerged 43 (12.8%) <- excluded by policy (MERGED-only default) | |
| # OPEN 11 ( 3.3%) <- correctly excluded, still in use | |
| # | |
| # The NO-PR bucket is not a stale tail — it is the LIVE growth. 145 of those 170 | |
| # carry a tip commit dated 2026-08, and 123 of them within the trailing 14 days: | |
| # agent branches pushed by a session that died, or never opened a PR at all. | |
| # | |
| # ⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it | |
| # addresses roughly 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?), | |
| # not an implementation detail, and is deliberately NOT taken here. | |
| # | |
| # ## The CLASSIFIER is still report-only by the TOKEN, and that has not changed | |
| # | |
| # The `sweep` job's grant is `contents: read`. Deleting a ref needs | |
| # `contents: write`. So a defect in the classification still cannot delete a | |
| # branch by itself: the token that job runs with remains structurally incapable | |
| # of it, and the deletion is a separate job downstream of an explicit hand-off. | |
| # That is the property to preserve when reviewing changes to this file — ⛔ do | |
| # not move the delete call into the `sweep` job, and ⛔ do not raise the | |
| # top-level `permissions:`. | |
| # | |
| # ## The deleting mode, as armed (2026-09-04) | |
| # | |
| # The three edits this header used to list as deliberately-undone, and what each | |
| # became: | |
| # 1. `permissions: contents: write` — done, but SCOPED TO THE `reap` JOB, not | |
| # raised at the top level. It is the only `contents: write` in the file. | |
| # 2. `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}` over the | |
| # `reapable` list — done, in `reap`, consuming the list `sweep` publishes | |
| # as a job output. ⛔ It consumes no other bucket. | |
| # 3. the CLOSED-unmerged policy — UNCHANGED and still MERGED-ONLY. Reaping | |
| # closed-but-unmerged branches discards work that never landed; the | |
| # maintainer's ruling of 2026-09-04 left that exclusion exactly where it | |
| # was. `closedUnmerged` remains report-only. | |
| # The grace period stays in place, as that note required. | |
| # | |
| # ## ⛔ What arming did NOT change | |
| # | |
| # ⛔ No seat identity gains delete rights — option B of #12771 stays refused. | |
| # The delete grant lives in this one job and expires with the run; no agent | |
| # container, credential or workflow elsewhere gains it. | |
| # ⛔ `PREFIX` is untouched: `copilot/` is on #13503's own release line and this | |
| # workflow must not reach it. | |
| # ⛔ `is-ancestor` remains forbidden, and the reverse check against it stays. | |
| # ⛔ The classifier's contract harness | |
| # (`scripts/check-merged-branch-reaper-outcome.mjs`) still drives the `sweep` | |
| # script and still holds `reapable => merged PR based on main` over every | |
| # scenario. Deletion deliberately lives OUTSIDE the extracted script so that | |
| # what the harness judges is unchanged in kind: a classification, not an action. | |
| # ## Why this workflow declares no check family | |
| # | |
| # `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, so there is no `check:*` script a card's file surface | |
| # could ever schedule. | |
| # | |
| # ⚠️ Since #13503 there IS a gate over this file — the classifier's contract, | |
| # `scripts/check-merged-branch-reaper-outcome.mjs` — and it does NOT change the | |
| # declaration below, deliberately. That gate does not run HERE; it runs in | |
| # lint.yml beside its two sibling harnesses (the cross-repo closer, the | |
| # merge-queue triage bot), where a red blocks and where the toolchain it needs | |
| # is already installed. This marker answers "does THIS workflow's own steps | |
| # discover a family", and the answer is still no. A card touching this file | |
| # still gets that gate scheduled, because the gate names this path in its own | |
| # source, which is how `dispatch-gates` derives a family in the first place. | |
| # | |
| # The `pull_request` filter below is not a verification step either — it exists | |
| # so that edits to this file exercise the sweep before they merge, and that is | |
| # not decoration: it is what produced this workflow's first real dry-run list | |
| # ("111 of 335 ... would be deleted", run 33318728567), which is the one human | |
| # look the 2026-08-28 ruling requires before deletion is ever enabled. Removing | |
| # the filter to satisfy the gate would delete that. | |
| # | |
| # dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run | |
| on: | |
| schedule: | |
| # Weekly, Monday 04:37 UTC. Calibrated to the 0.07% native leak rate above: | |
| # there is ~1 escaped branch per 10 days, so a daily sweep would spend its | |
| # API budget to find nothing six days out of seven. Offset off the top of | |
| # the hour because scheduled workflows queue behind everyone else's :00. | |
| - cron: '37 4 * * 1' | |
| workflow_dispatch: | |
| inputs: | |
| grace_days: | |
| description: 'Do not list a merged branch until its PR merged this many days ago.' | |
| required: false | |
| default: '7' | |
| dry_run: | |
| # ⛔ Defaults to TRUE, and the default is the point. "workflow_dispatch | |
| # kept for a manual run" reads fail-closed: a manual run is for LOOKING | |
| # at the list. Deleting by hand is an explicit `dry_run: false`, typed | |
| # by whoever wants it, on the one run they want it on. The scheduled | |
| # weekly run is the armed path the ruling authorises; this one is not. | |
| description: 'Classify and report only. Uncheck to actually delete the reapable branches.' | |
| type: boolean | |
| required: false | |
| default: true | |
| # Exercise the sweep on changes to itself, the same posture as | |
| # required-set-patrol.yml. This is what makes the FIRST delivery of this PR a | |
| # real would-delete list produced by a real runner rather than a claim about | |
| # one. Publishes no required context, so a red here blocks nothing. | |
| pull_request: | |
| paths: | |
| - '.github/workflows/merged-branch-reaper.yml' | |
| # Least privilege, and load-bearing: see "Two jobs, because `permissions:` is | |
| # scoped per JOB". This top-level grant is the DEFAULT and it is READ-ONLY — | |
| # `contents: read` lists branches, `pull-requests: read` reads PR state, and | |
| # neither can delete a ref. ⛔ Do not raise it: the one job that deletes | |
| # overrides it locally, and that override is the only `contents: write` in this | |
| # file precisely because this one stays read. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| # One sweep at a time. A scheduled run overlapping a manual dispatch would spend | |
| # two full classification passes (~340 API calls each) on one answer. | |
| concurrency: | |
| group: merged-branch-reaper-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| sweep: | |
| # ⛔ NOT a required context, and must never become one. A findings-based red | |
| # would block unrelated PRs on the state of somebody else's stale branch. | |
| name: Merged-branch sweep (report-only) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Stated at job level rather than inherited, so that reading THIS job never | |
| # requires scrolling to the top of the file to learn that it cannot delete. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| # The hand-off to `reap`. `reapable_branches` is the machine-readable half | |
| # of the list the step above prints; `reap` consumes it and computes nothing. | |
| outputs: | |
| reapable_count: ${{ steps.sweep.outputs.reapable }} | |
| reapable_branches: ${{ steps.sweep.outputs.reapable_branches }} | |
| steps: | |
| - name: Classify every claude/* branch by the state of its PR | |
| id: sweep | |
| uses: actions/github-script@v9 | |
| env: | |
| GRACE_DAYS: ${{ github.event.inputs.grace_days || '7' }} | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const PREFIX = 'claude/'; | |
| // The one base a merge has to land on for this reaper to call a | |
| // branch reapable. See "MERGED is not MERGED INTO `main`" above: | |
| // read off the PR record, never from git reachability. | |
| const BASE_REF = 'main'; | |
| const graceDays = Number(process.env.GRACE_DAYS || '7'); | |
| if (!Number.isFinite(graceDays) || graceDays < 0) { | |
| core.setFailed(`grace_days must be a non-negative number, got ${process.env.GRACE_DAYS}`); | |
| return; | |
| } | |
| const graceCutoff = Date.now() - graceDays * 24 * 60 * 60 * 1000; | |
| // --- 1. the population ------------------------------------------------- | |
| // Every branch, then filtered to the prefix. Counted here rather than | |
| // trusted from the card: `git ls-remote | wc -l` counts REFS, and what a | |
| // naive count gets wrong is exactly whether they are all dev branches. | |
| const allBranches = await github.paginate(github.rest.repos.listBranches, { | |
| owner, repo, per_page: 100, | |
| }); | |
| const candidates = allBranches.filter((b) => b.name.startsWith(PREFIX)); | |
| core.info(`branches on remote: ${allBranches.length}; matching ${PREFIX}: ${candidates.length}`); | |
| // --- 2. classify by PR state ------------------------------------------ | |
| // ⛔ NO `is-ancestor` ANYWHERE. The only question asked of each branch is | |
| // "does a PR whose head ref is this branch report a merged_at?". | |
| const buckets = { reapable: [], grace: [], open: [], mergedElsewhere: [], closedUnmerged: [], noPr: [], protectedBranch: [] }; | |
| for (const branch of candidates) { | |
| if (branch.protected) { | |
| buckets.protectedBranch.push({ branch: branch.name }); | |
| continue; | |
| } | |
| const prs = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: 'all', head: `${owner}:${branch.name}`, per_page: 100, | |
| }); | |
| // Defensive: the `head` filter is the instrument this whole workflow | |
| // rests on, so never accept a PR whose head ref is not literally this | |
| // branch. (A neighbouring endpoint, commits/{sha}/pulls, returns PRs | |
| // that merely CONTAIN the commit — a different question that reads | |
| // like the same one. Measured on 2026-08-30: it reported "has a PR" | |
| // for 4 of 5 branches that in fact had none of their own.) | |
| const mine = prs.filter((pr) => pr.head && pr.head.ref === branch.name); | |
| if (mine.length === 0) { | |
| buckets.noPr.push({ branch: branch.name }); | |
| continue; | |
| } | |
| const merged = mine.filter((pr) => pr.merged_at); | |
| const open = mine.filter((pr) => pr.state === 'open'); | |
| // The base-ref guard (#13503). `merged_at` is WHETHER it landed; | |
| // `base.ref` is WHERE. A PR record with no `base` at all answers | |
| // neither, so it is counted as NOT main — the fail-closed | |
| // direction, because the cost of being wrong here is a deleted | |
| // branch. | |
| const intoMain = merged.filter((pr) => pr.base && pr.base.ref === BASE_REF); | |
| if (merged.length > 0) { | |
| // Newest merge wins: a branch reused across two PRs is safe to reap | |
| // only once the LAST of them has landed and aged out of the grace | |
| // window. | |
| const newest = merged | |
| .slice() | |
| .sort((a, b) => new Date(b.merged_at) - new Date(a.merged_at))[0]; | |
| // A branch with a merged PR AND a still-open PR is in use. Never | |
| // reapable, whatever the merge says. | |
| if (open.length > 0) { | |
| buckets.open.push({ branch: branch.name, pr: open[0].number, note: 'also has a merged PR' }); | |
| } else if (intoMain.length === 0) { | |
| // Merged, but never onto `main`. Held and REPORTED, never | |
| // reaped. The grace window is deliberately not consulted: | |
| // this exclusion does not age out, it is answered by a human | |
| // reading the list or by the base branch landing later. | |
| buckets.mergedElsewhere.push({ | |
| branch: branch.name, | |
| pr: newest.number, | |
| merged_at: newest.merged_at, | |
| base: (newest.base && newest.base.ref) || '(no base on the PR record)', | |
| }); | |
| } else if (new Date(newest.merged_at).getTime() > graceCutoff) { | |
| buckets.grace.push({ branch: branch.name, pr: newest.number, merged_at: newest.merged_at }); | |
| } else { | |
| // `newest` is still the newest of ALL merged PRs, not of the | |
| // main-based ones: the grace window must not shorten because | |
| // of the guard. The guard only ever REMOVES branches from | |
| // this bucket. | |
| buckets.reapable.push({ | |
| branch: branch.name, | |
| pr: newest.number, | |
| merged_at: newest.merged_at, | |
| base_pr: intoMain[0].number, | |
| }); | |
| } | |
| } else if (open.length > 0) { | |
| buckets.open.push({ branch: branch.name, pr: open[0].number }); | |
| } else { | |
| buckets.closedUnmerged.push({ branch: branch.name, pr: mine[0].number }); | |
| } | |
| } | |
| // --- 3. render --------------------------------------------------------- | |
| const n = (a) => a.length; | |
| const total = candidates.length; | |
| const pct = (a) => (total ? ((100 * n(a)) / total).toFixed(1) : '0.0'); | |
| const sample = (arr, fmt, k = 8) => | |
| arr.slice(0, k).map(fmt).join('\n') + (arr.length > k ? `\n_...and ${arr.length - k} more_` : ''); | |
| const lines = []; | |
| lines.push('## Merged-branch reaper — classification. THIS JOB deletes nothing.'); | |
| lines.push(''); | |
| lines.push(`Criterion: **a PR whose head ref is the branch reports \`merged_at\`**. ⛔ Never \`is-ancestor\`.`); | |
| lines.push(`Grace period: **${graceDays} day(s)** since merge. Token grant: \`contents: read\` — this job cannot delete a ref; only the \`reap\` job can, and only over the ✅ row below.`); | |
| lines.push(''); | |
| lines.push(`### Population: ${total} \`${PREFIX}\` branches (of ${allBranches.length} on the remote)`); | |
| lines.push(''); | |
| lines.push('| bucket | count | share | reaped? |'); | |
| lines.push('|---|---:|---:|---|'); | |
| lines.push(`| MERGED, past grace | ${n(buckets.reapable)} | ${pct(buckets.reapable)}% | ✅ would delete |`); | |
| lines.push(`| MERGED, within grace | ${n(buckets.grace)} | ${pct(buckets.grace)}% | held ${graceDays}d |`); | |
| lines.push(`| OPEN PR | ${n(buckets.open)} | ${pct(buckets.open)}% | ⛔ excluded — in use |`); | |
| lines.push(`| MERGED, but not into \`${BASE_REF}\` | ${n(buckets.mergedElsewhere)} | ${pct(buckets.mergedElsewhere)}% | ⛔ excluded — base-ref guard |`); | |
| lines.push(`| CLOSED, unmerged | ${n(buckets.closedUnmerged)} | ${pct(buckets.closedUnmerged)}% | ⛔ excluded — MERGED-only policy |`); | |
| lines.push(`| NO PR at all | ${n(buckets.noPr)} | ${pct(buckets.noPr)}% | ⛔ unreachable by construction |`); | |
| lines.push(`| protected | ${n(buckets.protectedBranch)} | ${pct(buckets.protectedBranch)}% | ⛔ excluded |`); | |
| lines.push(''); | |
| lines.push(`### ✅ Would delete (${n(buckets.reapable)})`); | |
| lines.push(''); | |
| lines.push(n(buckets.reapable) | |
| ? sample(buckets.reapable, (r) => `- \`${r.branch}\` — PR #${r.pr}, merged ${r.merged_at}`, 40) | |
| : '_none_'); | |
| lines.push(''); | |
| lines.push('### ⛔ Excluded, with the reason for each'); | |
| lines.push(''); | |
| lines.push(`**OPEN PR (${n(buckets.open)})** — still in use:`); | |
| lines.push(n(buckets.open) ? sample(buckets.open, (r) => `- \`${r.branch}\` — PR #${r.pr}${r.note ? ` (${r.note})` : ''}`) : '_none_'); | |
| lines.push(''); | |
| 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:`); | |
| lines.push(n(buckets.mergedElsewhere) | |
| ? sample(buckets.mergedElsewhere, (r) => `- \`${r.branch}\` — PR #${r.pr} merged into \`${r.base}\` at ${r.merged_at}`, 40) | |
| : '_none_'); | |
| lines.push(''); | |
| lines.push(`**CLOSED, unmerged (${n(buckets.closedUnmerged)})** — work that never landed; MERGED-only is the default policy:`); | |
| lines.push(n(buckets.closedUnmerged) ? sample(buckets.closedUnmerged, (r) => `- \`${r.branch}\` — PR #${r.pr}`) : '_none_'); | |
| lines.push(''); | |
| lines.push(`**MERGED but within the ${graceDays}-day grace window (${n(buckets.grace)})**:`); | |
| lines.push(n(buckets.grace) ? sample(buckets.grace, (r) => `- \`${r.branch}\` — PR #${r.pr}, merged ${r.merged_at}`) : '_none_'); | |
| lines.push(''); | |
| lines.push(`**NO PR at all (${n(buckets.noPr)})** — ⚠️ this reaper can never touch these:`); | |
| lines.push(n(buckets.noPr) ? sample(buckets.noPr, (r) => `- \`${r.branch}\``) : '_none_'); | |
| lines.push(''); | |
| await core.summary.addRaw(lines.join('\n')).write(); | |
| const payload = { generated_at: new Date().toISOString(), grace_days: graceDays, total_branches: allBranches.length, prefix_branches: total, buckets }; | |
| require('fs').writeFileSync(`${process.env.RUNNER_TEMP}/branch-reaper-report.json`, JSON.stringify(payload, null, 2)); | |
| core.notice( | |
| `Sweep: ${n(buckets.reapable)} of ${total} ${PREFIX} branches are reapable and are handed to the \`reap\` job. ` | |
| + `Report-only, never reaped: ${n(buckets.mergedElsewhere)} merged somewhere other than \`${BASE_REF}\` and held by the base-ref guard; ` | |
| + `${n(buckets.noPr)} have no PR and are unreachable by this criterion; ${n(buckets.open)} have an open PR; ` | |
| + `${n(buckets.closedUnmerged)} closed unmerged; ${n(buckets.grace)} merged within the ${graceDays}-day grace window; ` | |
| + `${n(buckets.protectedBranch)} protected. This job deleted nothing — its token grant is \`contents: read\`.`, | |
| ); | |
| core.setOutput('reapable', String(n(buckets.reapable))); | |
| core.setOutput('no_pr', String(n(buckets.noPr))); | |
| core.setOutput('merged_elsewhere', String(n(buckets.mergedElsewhere))); | |
| // The machine-readable half of the deletion list, and the ONLY | |
| // thing the `reap` job consumes. It is the `reapable` bucket and | |
| // nothing else, so what deletes is exactly what was classified | |
| // reapable and exactly what the log above printed. The contract | |
| // harness pins that equality (scenarios G1/G2/R1, mutations | |
| // M13/M14) so no later edit can widen the list without a red. | |
| core.setOutput('reapable_branches', JSON.stringify(buckets.reapable.map((r) => r.branch))); | |
| - name: Upload the full classification | |
| # `always()`: a run whose report is missing because an earlier step died | |
| # is itself the signal, and the partial file is worth more than nothing. | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: branch-reaper-report | |
| path: ${{ runner.temp }}/branch-reaper-report.json | |
| if-no-files-found: warn | |
| reap: | |
| # ⛔ NOT a required context either, and must never become one. | |
| name: Delete the reapable branches | |
| needs: sweep | |
| # ⛔⛔ The fence that keeps a dry run dry. Three clauses, and the second is | |
| # deliberately redundant with the third: | |
| # - `success()` is WRITTEN rather than left to GitHub's implicit wrapper | |
| # (#5343): if `sweep` died there is no classification to act on, and | |
| # "the classifier failed" must never read the same as "nothing to do". | |
| # - `!= 'pull_request'` on its own clause, because it is the one exclusion | |
| # a reader must be able to find without evaluating the rest. Editing | |
| # this workflow exercises the sweep; it must never delete a branch. | |
| # - then an ALLOWLIST of the two armed paths: the weekly `schedule` the | |
| # 2026-09-04 ruling authorises, and a `workflow_dispatch` on which the | |
| # operator explicitly unset the `dry_run` default. | |
| # An allowlist is used rather than a denylist so that a trigger added to | |
| # this workflow later is DRY by default and has to be armed on purpose. | |
| if: >- | |
| success() | |
| && github.event_name != 'pull_request' | |
| && (github.event_name == 'schedule' | |
| || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false)) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # ⛔ The ONLY `contents: write` in this file, and the only job that can | |
| # delete a ref. It expires with the run: no seat identity, container or | |
| # credential gains delete rights from it (#12771 option B stays refused). | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Delete every branch in the reapable bucket | |
| uses: actions/github-script@v9 | |
| env: | |
| REAPABLE: ${{ needs.sweep.outputs.reapable_branches }} | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| // Re-checked here, not trusted. `sweep` only ever puts prefixed | |
| // branches in the bucket, so a violation is a corrupted hand-off | |
| // rather than a branch to delete anyway — and the cost of being | |
| // wrong in this job is a branch that exists nowhere else. | |
| const PREFIX = 'claude/'; | |
| let branches; | |
| try { | |
| branches = JSON.parse(process.env.REAPABLE || '[]'); | |
| } catch (err) { | |
| core.setFailed(`the reapable list handed over by \`sweep\` is not JSON -- ${err.message}. Nothing was deleted.`); | |
| return; | |
| } | |
| if (!Array.isArray(branches)) { | |
| core.setFailed('the reapable list handed over by `sweep` is not an array. Nothing was deleted.'); | |
| return; | |
| } | |
| const stray = branches.filter((b) => typeof b !== 'string' || !b.startsWith(PREFIX)); | |
| if (stray.length > 0) { | |
| core.setFailed( | |
| `the reapable list contains ${stray.length} entry/entries outside \`${PREFIX}\`: ` + | |
| `${stray.join(', ')}. Nothing was deleted -- a hand-off this wrong is not one to act on partially.`, | |
| ); | |
| return; | |
| } | |
| if (branches.length === 0) { | |
| core.info(`The sweep classified nothing as reapable. Nothing to delete.`); | |
| return; | |
| } | |
| // The audit trail the ruling asks for: the WHOLE list, in the run | |
| // log, before the first deletion — so a run that dies halfway still | |
| // leaves behind what it intended to do. | |
| core.info(`Deleting ${branches.length} branch(es) classified \`reapable\` by the sweep job:`); | |
| for (const branch of branches) core.info(` - ${branch}`); | |
| const deleted = []; | |
| const alreadyGone = []; | |
| const failed = []; | |
| for (const branch of branches) { | |
| core.info(`deleting ${branch}`); | |
| try { | |
| await github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }); | |
| deleted.push(branch); | |
| } catch (err) { | |
| // A ref already gone is not a failure: `delete_branch_on_merge` | |
| // reaps 1385 of every 1386 natively, so losing the race to the | |
| // platform is the EXPECTED outcome, not an error. | |
| if (err.status === 404 || err.status === 422) { | |
| alreadyGone.push(branch); | |
| core.info(` already gone: ${branch}`); | |
| continue; | |
| } | |
| failed.push({ branch, status: err.status, message: err.message }); | |
| core.warning(` FAILED ${branch}: ${err.status} ${err.message}`); | |
| } | |
| } | |
| const lines = ['## Merged-branch reaper — DELETION PASS', '']; | |
| lines.push(`Deleted **${deleted.length}**, already gone **${alreadyGone.length}**, failed **${failed.length}**, of ${branches.length} branch(es) the sweep classified \`reapable\`.`); | |
| lines.push(''); | |
| lines.push(`⛔ Only the \`reapable\` bucket is touched. Every other bucket in the sweep's report is report-only and was not read by this job.`); | |
| lines.push(''); | |
| for (const b of deleted) lines.push(`- ✅ deleted \`${b}\``); | |
| for (const b of alreadyGone) lines.push(`- • already gone \`${b}\``); | |
| if (failed.length > 0) { | |
| lines.push(''); | |
| lines.push(`### ⛔ ${failed.length} deletion(s) failed`); | |
| lines.push(''); | |
| for (const f of failed) lines.push(`- \`${f.branch}\` — ${f.status} ${f.message}`); | |
| } | |
| await core.summary.addRaw(lines.join('\n')).write(); | |
| core.notice(`Reaped ${deleted.length} of ${branches.length} ${PREFIX} branches (${alreadyGone.length} already gone, ${failed.length} failed).`); | |
| if (failed.length > 0) { | |
| core.setFailed(`${failed.length} branch deletion(s) failed -- see the log for each status.`); | |
| } |