Skip to content

Commit 7a52eae

Browse files
committed
ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches
Flips .github/workflows/merged-branch-reaper.yml from report-only to the scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue #12771, decision batch #30), reaffirming the 2026-08-31 ruling under the base-ref guard PR #15144 landed. Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per job. `sweep` keeps `contents: read` + `pull-requests: read` and remains structurally incapable of deleting a ref; `reap` holds the only `contents: write` in the file, consumes the `reapable` list `sweep` publishes as a job output, and computes no classification of its own. Fences: - `reap` never runs on `pull_request` — the self-exercising run stays a dry run — and its `if:` is an allowlist of `schedule` plus a `workflow_dispatch` on which the operator explicitly set `dry_run: false`. - the new `dry_run` workflow_dispatch input defaults to true, so the manual path is fail-closed. - the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule and the `is-ancestor` prohibition are all untouched. - the whole deletion list is printed to the run log before the first delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
1 parent 50d6c92 commit 7a52eae

1 file changed

Lines changed: 226 additions & 28 deletions

File tree

‎.github/workflows/merged-branch-reaper.yml‎

Lines changed: 226 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
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
#
@@ -172,6 +224,16 @@ on:
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
@@ -180,9 +242,12 @@ on:
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.
186251
permissions:
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

Comments
 (0)