1- name : Merged-Branch Reaper (report-only)
1+ name : Merged-Branch Reaper
22
33# The standing sweep for #12771: `claude/*` branches that outlived the PR they
44# were the head of.
55#
6- # ⛔⛔ THIS WORKFLOW DELETES NOTHING. It classifies and reports. The deleting
7- # mode is deliberately ABSENT, not merely switched off — see "Enabling the
8- # deleting mode" at the bottom of this header. The maintainer's ruling of
9- # 2026-08-28 requires that the first delivery produce the would-delete list for
10- # one human look BEFORE deletion is ever enabled.
6+ # ⚠️⚠️ THIS WORKFLOW DELETES BRANCHES. It was ARMED on 2026-09-04 by the
7+ # maintainer's ruling on #12771 (decision batch #30), which reaffirmed the
8+ # ruling of 2026-08-31 once the base-ref guard of #13503 had landed (PR #15144).
9+ # Before that it classified and reported and the deleting mode was absent; the
10+ # history below is kept because every fence it records is still load-bearing.
11+ #
12+ # Exactly what deletes, and what never does:
13+ #
14+ # DELETES the `reapable` bucket, and nothing else: a `claude/*` branch whose
15+ # OWN pull request reports `merged_at` AND `base.ref === 'main'`,
16+ # past the grace window, with no open PR of its own.
17+ # NEVER `mergedElsewhere`, `closedUnmerged`, `noPr`, `open`, `grace`,
18+ # `protectedBranch`. All six stay REPORT-ONLY — listed in the run
19+ # log every sweep, never touched. Widening past `reapable` is a new
20+ # maintainer ruling, ⛔ not an edit to this file.
21+ # NEVER on `pull_request`. The self-exercising run below stays a DRY RUN,
22+ # which is what keeps "edit this workflow" from meaning "delete
23+ # branches". The `reap` job's `if:` excludes it explicitly.
24+ # NEVER on a `workflow_dispatch` unless the operator sets `dry_run: false`.
25+ # That input defaults to TRUE, so the manual path is fail-closed: a
26+ # manual run is for LOOKING at the list unless someone says otherwise.
27+ #
28+ # The deletion list is printed to the run log BEFORE the first deletion. That
29+ # log is the audit trail the ruling asks for.
30+ #
31+ # ## Two jobs, because `permissions:` is scoped per JOB
32+ #
33+ # `sweep` classifies. It runs with `contents: read` + `pull-requests: read` and
34+ # is structurally incapable of deleting a ref — the same token grant it was
35+ # reviewed under, and the one every scenario of the contract harness drives.
36+ # `reap` consumes the `reapable` list `sweep` published as a job output and is
37+ # the ONLY job in this file holding `contents: write`.
38+ #
39+ # The split is the point. Deleting needs a write grant; classifying must not
40+ # have one. A single job would have handed the write token to the ~140 lines
41+ # that decide WHICH branches, where a defect turns into a deleted branch. As
42+ # split, the write token reaches one job that makes no classification decision
43+ # of its own: it re-checks the prefix, prints the list, and calls DELETE.
1144#
1245# ## The criterion is PR state MERGED. ⛔⛔ NEVER `is-ancestor`.
1346#
@@ -112,25 +145,44 @@ name: Merged-Branch Reaper (report-only)
112145# NEW RULING (what proves such a branch is abandoned rather than in flight?),
113146# not an implementation detail, and is deliberately NOT taken here.
114147#
115- # ## Report-only is enforced by the TOKEN, not just by the code
116- #
117- # `permissions: contents: read` below is the whole grant. Deleting a ref needs
118- # `contents: write`. So even a defect in the classification cannot delete a
119- # branch: the token this job runs with is structurally incapable of it. That is
120- # the property to preserve when reviewing changes to this file.
121- #
122- # ## Enabling the deleting mode (NOT done here — the maintainer's call)
123- #
124- # Three edits, deliberately left undone so that enabling is a reviewed diff and
125- # not a flipped default:
126- # 1. raise `permissions:` to `contents: write`;
127- # 2. add a step calling `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}`
128- # over the `reapable` list this job already computes and uploads;
129- # 3. decide the CLOSED-unmerged policy. The default here is MERGED-ONLY.
130- # Reaping closed-but-unmerged branches discards work that was never
131- # merged — that is the maintainer's call to make, not this workflow's to
132- # assume.
133- # The grace period below should stay in place when that happens.
148+ # ## The CLASSIFIER is still report-only by the TOKEN, and that has not changed
149+ #
150+ # The `sweep` job's grant is `contents: read`. Deleting a ref needs
151+ # `contents: write`. So a defect in the classification still cannot delete a
152+ # branch by itself: the token that job runs with remains structurally incapable
153+ # of it, and the deletion is a separate job downstream of an explicit hand-off.
154+ # That is the property to preserve when reviewing changes to this file — ⛔ do
155+ # not move the delete call into the `sweep` job, and ⛔ do not raise the
156+ # top-level `permissions:`.
157+ #
158+ # ## The deleting mode, as armed (2026-09-04)
159+ #
160+ # The three edits this header used to list as deliberately-undone, and what each
161+ # became:
162+ # 1. `permissions: contents: write` — done, but SCOPED TO THE `reap` JOB, not
163+ # raised at the top level. It is the only `contents: write` in the file.
164+ # 2. `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}` over the
165+ # `reapable` list — done, in `reap`, consuming the list `sweep` publishes
166+ # as a job output. ⛔ It consumes no other bucket.
167+ # 3. the CLOSED-unmerged policy — UNCHANGED and still MERGED-ONLY. Reaping
168+ # closed-but-unmerged branches discards work that never landed; the
169+ # maintainer's ruling of 2026-09-04 left that exclusion exactly where it
170+ # was. `closedUnmerged` remains report-only.
171+ # The grace period stays in place, as that note required.
172+ #
173+ # ## ⛔ What arming did NOT change
174+ #
175+ # ⛔ No seat identity gains delete rights — option B of #12771 stays refused.
176+ # The delete grant lives in this one job and expires with the run; no agent
177+ # container, credential or workflow elsewhere gains it.
178+ # ⛔ `PREFIX` is untouched: `copilot/` is on #13503's own release line and this
179+ # workflow must not reach it.
180+ # ⛔ `is-ancestor` remains forbidden, and the reverse check against it stays.
181+ # ⛔ The classifier's contract harness
182+ # (`scripts/check-merged-branch-reaper-outcome.mjs`) still drives the `sweep`
183+ # script and still holds `reapable => merged PR based on main` over every
184+ # scenario. Deletion deliberately lives OUTSIDE the extracted script so that
185+ # what the harness judges is unchanged in kind: a classification, not an action.
134186
135187# ## Why this workflow declares no check family
136188#
172224 description : ' Do not list a merged branch until its PR merged this many days ago.'
173225 required : false
174226 default : ' 7'
227+ dry_run :
228+ # ⛔ Defaults to TRUE, and the default is the point. "workflow_dispatch
229+ # kept for a manual run" reads fail-closed: a manual run is for LOOKING
230+ # at the list. Deleting by hand is an explicit `dry_run: false`, typed
231+ # by whoever wants it, on the one run they want it on. The scheduled
232+ # weekly run is the armed path the ruling authorises; this one is not.
233+ description : ' Classify and report only. Uncheck to actually delete the reapable branches.'
234+ type : boolean
235+ required : false
236+ default : true
175237 # Exercise the sweep on changes to itself, the same posture as
176238 # required-set-patrol.yml. This is what makes the FIRST delivery of this PR a
177239 # real would-delete list produced by a real runner rather than a claim about
180242 paths :
181243 - ' .github/workflows/merged-branch-reaper.yml'
182244
183- # Least privilege, and load-bearing: see "Report-only is enforced by the TOKEN".
184- # `contents: read` lists branches; `pull-requests: read` reads PR state. Neither
185- # can delete a ref.
245+ # Least privilege, and load-bearing: see "Two jobs, because `permissions:` is
246+ # scoped per JOB". This top-level grant is the DEFAULT and it is READ-ONLY —
247+ # `contents: read` lists branches, `pull-requests: read` reads PR state, and
248+ # neither can delete a ref. ⛔ Do not raise it: the one job that deletes
249+ # overrides it locally, and that override is the only `contents: write` in this
250+ # file precisely because this one stays read.
186251permissions :
187252 contents : read
188253 pull-requests : read
@@ -200,6 +265,16 @@ jobs:
200265 name : Merged-branch sweep (report-only)
201266 runs-on : ubuntu-latest
202267 timeout-minutes : 15
268+ # Stated at job level rather than inherited, so that reading THIS job never
269+ # requires scrolling to the top of the file to learn that it cannot delete.
270+ permissions :
271+ contents : read
272+ pull-requests : read
273+ # The hand-off to `reap`. `reapable_branches` is the machine-readable half
274+ # of the list the step above prints; `reap` consumes it and computes nothing.
275+ outputs :
276+ reapable_count : ${{ steps.sweep.outputs.reapable }}
277+ reapable_branches : ${{ steps.sweep.outputs.reapable_branches }}
203278 steps :
204279 - name : Classify every claude/* branch by the state of its PR
205280 id : sweep
@@ -369,6 +444,13 @@ jobs:
369444 core.setOutput('reapable', String(n(buckets.reapable)));
370445 core.setOutput('no_pr', String(n(buckets.noPr)));
371446 core.setOutput('merged_elsewhere', String(n(buckets.mergedElsewhere)));
447+ // The machine-readable half of the deletion list, and the ONLY
448+ // thing the `reap` job consumes. It is the `reapable` bucket and
449+ // nothing else, so what deletes is exactly what was classified
450+ // reapable and exactly what the log above printed. The contract
451+ // harness pins that equality (scenarios G1/G2/R1, mutations
452+ // M13/M14) so no later edit can widen the list without a red.
453+ core.setOutput('reapable_branches', JSON.stringify(buckets.reapable.map((r) => r.branch)));
372454
373455 - name : Upload the full classification
374456 # `always()`: a run whose report is missing because an earlier step died
@@ -379,3 +461,119 @@ jobs:
379461 name : branch-reaper-report
380462 path : ${{ runner.temp }}/branch-reaper-report.json
381463 if-no-files-found : warn
464+
465+ reap :
466+ # ⛔ NOT a required context either, and must never become one.
467+ name : Delete the reapable branches
468+ needs : sweep
469+ # ⛔⛔ The fence that keeps a dry run dry. Three clauses, and the second is
470+ # deliberately redundant with the third:
471+ # - `success()` is WRITTEN rather than left to GitHub's implicit wrapper
472+ # (#5343): if `sweep` died there is no classification to act on, and
473+ # "the classifier failed" must never read the same as "nothing to do".
474+ # - `!= 'pull_request'` on its own clause, because it is the one exclusion
475+ # a reader must be able to find without evaluating the rest. Editing
476+ # this workflow exercises the sweep; it must never delete a branch.
477+ # - then an ALLOWLIST of the two armed paths: the weekly `schedule` the
478+ # 2026-09-04 ruling authorises, and a `workflow_dispatch` on which the
479+ # operator explicitly unset the `dry_run` default.
480+ # An allowlist is used rather than a denylist so that a trigger added to
481+ # this workflow later is DRY by default and has to be armed on purpose.
482+ if : >-
483+ success()
484+ && github.event_name != 'pull_request'
485+ && (github.event_name == 'schedule'
486+ || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false))
487+ runs-on : ubuntu-latest
488+ timeout-minutes : 15
489+ # ⛔ The ONLY `contents: write` in this file, and the only job that can
490+ # delete a ref. It expires with the run: no seat identity, container or
491+ # credential gains delete rights from it (#12771 option B stays refused).
492+ permissions :
493+ contents : write
494+ steps :
495+ - name : Delete every branch in the reapable bucket
496+ uses : actions/github-script@v9
497+ env :
498+ REAPABLE : ${{ needs.sweep.outputs.reapable_branches }}
499+ with :
500+ script : |
501+ const owner = context.repo.owner;
502+ const repo = context.repo.repo;
503+ // Re-checked here, not trusted. `sweep` only ever puts prefixed
504+ // branches in the bucket, so a violation is a corrupted hand-off
505+ // rather than a branch to delete anyway — and the cost of being
506+ // wrong in this job is a branch that exists nowhere else.
507+ const PREFIX = 'claude/';
508+
509+ let branches;
510+ try {
511+ branches = JSON.parse(process.env.REAPABLE || '[]');
512+ } catch (err) {
513+ core.setFailed(`the reapable list handed over by \`sweep\` is not JSON -- ${err.message}. Nothing was deleted.`);
514+ return;
515+ }
516+ if (!Array.isArray(branches)) {
517+ core.setFailed('the reapable list handed over by `sweep` is not an array. Nothing was deleted.');
518+ return;
519+ }
520+ const stray = branches.filter((b) => typeof b !== 'string' || !b.startsWith(PREFIX));
521+ if (stray.length > 0) {
522+ core.setFailed(
523+ `the reapable list contains ${stray.length} entry/entries outside \`${PREFIX}\`: ` +
524+ `${stray.join(', ')}. Nothing was deleted -- a hand-off this wrong is not one to act on partially.`,
525+ );
526+ return;
527+ }
528+ if (branches.length === 0) {
529+ core.info(`The sweep classified nothing as reapable. Nothing to delete.`);
530+ return;
531+ }
532+
533+ // The audit trail the ruling asks for: the WHOLE list, in the run
534+ // log, before the first deletion — so a run that dies halfway still
535+ // leaves behind what it intended to do.
536+ core.info(`Deleting ${branches.length} branch(es) classified \`reapable\` by the sweep job:`);
537+ for (const branch of branches) core.info(` - ${branch}`);
538+
539+ const deleted = [];
540+ const alreadyGone = [];
541+ const failed = [];
542+ for (const branch of branches) {
543+ core.info(`deleting ${branch}`);
544+ try {
545+ await github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` });
546+ deleted.push(branch);
547+ } catch (err) {
548+ // A ref already gone is not a failure: `delete_branch_on_merge`
549+ // reaps 1385 of every 1386 natively, so losing the race to the
550+ // platform is the EXPECTED outcome, not an error.
551+ if (err.status === 404 || err.status === 422) {
552+ alreadyGone.push(branch);
553+ core.info(` already gone: ${branch}`);
554+ continue;
555+ }
556+ failed.push({ branch, status: err.status, message: err.message });
557+ core.warning(` FAILED ${branch}: ${err.status} ${err.message}`);
558+ }
559+ }
560+
561+ const lines = ['## Merged-branch reaper — DELETION PASS', ''];
562+ lines.push(`Deleted **${deleted.length}**, already gone **${alreadyGone.length}**, failed **${failed.length}**, of ${branches.length} branch(es) the sweep classified \`reapable\`.`);
563+ lines.push('');
564+ 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.`);
565+ lines.push('');
566+ for (const b of deleted) lines.push(`- ✅ deleted \`${b}\``);
567+ for (const b of alreadyGone) lines.push(`- • already gone \`${b}\``);
568+ if (failed.length > 0) {
569+ lines.push('');
570+ lines.push(`### ⛔ ${failed.length} deletion(s) failed`);
571+ lines.push('');
572+ for (const f of failed) lines.push(`- \`${f.branch}\` — ${f.status} ${f.message}`);
573+ }
574+ await core.summary.addRaw(lines.join('\n')).write();
575+
576+ core.notice(`Reaped ${deleted.length} of ${branches.length} ${PREFIX} branches (${alreadyGone.length} already gone, ${failed.length} failed).`);
577+ if (failed.length > 0) {
578+ core.setFailed(`${failed.length} branch deletion(s) failed -- see the log for each status.`);
579+ }
0 commit comments