Skip to content

Commit d4d5646

Browse files
claude[bot]claude
andauthored
ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches (#15224)
* 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 * test(reaper): pin the deletion hand-off and fence the delete job structurally The contract harness drives the `sweep` classifier and can say nothing about the job that deletes — deletion deliberately lives outside the extracted script, so what the harness judges stays a classification rather than an action. Two additions close that gap. 1. The hand-off. `sweep` now publishes `reapable_branches`, the machine-readable half of the list it prints, and `reap` consumes that and nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable bucket — same members, same order — over a population carrying one branch in every bucket, and mutations M13/M14 drive both directions red (held branches leaking in; the list not published at all). 2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the delete job's structure: its `if:` excludes `pull_request` and gates `workflow_dispatch` on `inputs.dry_run == false`; it declares `contents: write` and is the ONLY job in the file that does; the top-level grant stays `contents: read`; it still `needs: sweep`. New self-test battery 6 drives six mutations of the workflow text to red, each asserting its anchor was present first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * ci(reaper): put every excluded bucket on the run-log audit line The maintainer's ruling names the run log as the audit trail, and the notice line named three of the seven buckets — reapable, mergedElsewhere, noPr. The other four (open, closedUnmerged, grace, protectedBranch) lived only in the step summary and the uploaded artifact, so the log alone could not answer "what did it hold back, and why". Also retires two strings that stopped being true when the reaper was armed: the summary heading said "DRY RUN. Nothing was deleted." of a run that may now delete in a later job, and the notice said "Nothing was deleted" of the whole run rather than of this job. Both now speak for the `sweep` job only, which is the thing they were ever really asserting — its token grant is `contents: read` and that has not changed. No classification changed: the buckets, the guard, the grace window and the step outputs are byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c393b56 commit d4d5646

2 files changed

Lines changed: 422 additions & 32 deletions

File tree

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

Lines changed: 235 additions & 31 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
@@ -318,10 +393,10 @@ jobs:
318393
arr.slice(0, k).map(fmt).join('\n') + (arr.length > k ? `\n_...and ${arr.length - k} more_` : '');
319394
320395
const lines = [];
321-
lines.push('## Merged-branch reaper — DRY RUN. Nothing was deleted.');
396+
lines.push('## Merged-branch reaper — classification. THIS JOB deletes nothing.');
322397
lines.push('');
323398
lines.push(`Criterion: **a PR whose head ref is the branch reports \`merged_at\`**. ⛔ Never \`is-ancestor\`.`);
324-
lines.push(`Grace period: **${graceDays} day(s)** since merge. Token grant: \`contents: read\` — this job cannot delete a ref.`);
399+
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.`);
325400
lines.push('');
326401
lines.push(`### Population: ${total} \`${PREFIX}\` branches (of ${allBranches.length} on the remote)`);
327402
lines.push('');
@@ -365,10 +440,23 @@ jobs:
365440
const payload = { generated_at: new Date().toISOString(), grace_days: graceDays, total_branches: allBranches.length, prefix_branches: total, buckets };
366441
require('fs').writeFileSync(`${process.env.RUNNER_TEMP}/branch-reaper-report.json`, JSON.stringify(payload, null, 2));
367442
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.`);
443+
core.notice(
444+
`Sweep: ${n(buckets.reapable)} of ${total} ${PREFIX} branches are reapable and are handed to the \`reap\` job. `
445+
+ `Report-only, never reaped: ${n(buckets.mergedElsewhere)} merged somewhere other than \`${BASE_REF}\` and held by the base-ref guard; `
446+
+ `${n(buckets.noPr)} have no PR and are unreachable by this criterion; ${n(buckets.open)} have an open PR; `
447+
+ `${n(buckets.closedUnmerged)} closed unmerged; ${n(buckets.grace)} merged within the ${graceDays}-day grace window; `
448+
+ `${n(buckets.protectedBranch)} protected. This job deleted nothing — its token grant is \`contents: read\`.`,
449+
);
369450
core.setOutput('reapable', String(n(buckets.reapable)));
370451
core.setOutput('no_pr', String(n(buckets.noPr)));
371452
core.setOutput('merged_elsewhere', String(n(buckets.mergedElsewhere)));
453+
// The machine-readable half of the deletion list, and the ONLY
454+
// thing the `reap` job consumes. It is the `reapable` bucket and
455+
// nothing else, so what deletes is exactly what was classified
456+
// reapable and exactly what the log above printed. The contract
457+
// harness pins that equality (scenarios G1/G2/R1, mutations
458+
// M13/M14) so no later edit can widen the list without a red.
459+
core.setOutput('reapable_branches', JSON.stringify(buckets.reapable.map((r) => r.branch)));
372460
373461
- name: Upload the full classification
374462
# `always()`: a run whose report is missing because an earlier step died
@@ -379,3 +467,119 @@ jobs:
379467
name: branch-reaper-report
380468
path: ${{ runner.temp }}/branch-reaper-report.json
381469
if-no-files-found: warn
470+
471+
reap:
472+
# ⛔ NOT a required context either, and must never become one.
473+
name: Delete the reapable branches
474+
needs: sweep
475+
# ⛔⛔ The fence that keeps a dry run dry. Three clauses, and the second is
476+
# deliberately redundant with the third:
477+
# - `success()` is WRITTEN rather than left to GitHub's implicit wrapper
478+
# (#5343): if `sweep` died there is no classification to act on, and
479+
# "the classifier failed" must never read the same as "nothing to do".
480+
# - `!= 'pull_request'` on its own clause, because it is the one exclusion
481+
# a reader must be able to find without evaluating the rest. Editing
482+
# this workflow exercises the sweep; it must never delete a branch.
483+
# - then an ALLOWLIST of the two armed paths: the weekly `schedule` the
484+
# 2026-09-04 ruling authorises, and a `workflow_dispatch` on which the
485+
# operator explicitly unset the `dry_run` default.
486+
# An allowlist is used rather than a denylist so that a trigger added to
487+
# this workflow later is DRY by default and has to be armed on purpose.
488+
if: >-
489+
success()
490+
&& github.event_name != 'pull_request'
491+
&& (github.event_name == 'schedule'
492+
|| (github.event_name == 'workflow_dispatch' && inputs.dry_run == false))
493+
runs-on: ubuntu-latest
494+
timeout-minutes: 15
495+
# ⛔ The ONLY `contents: write` in this file, and the only job that can
496+
# delete a ref. It expires with the run: no seat identity, container or
497+
# credential gains delete rights from it (#12771 option B stays refused).
498+
permissions:
499+
contents: write
500+
steps:
501+
- name: Delete every branch in the reapable bucket
502+
uses: actions/github-script@v9
503+
env:
504+
REAPABLE: ${{ needs.sweep.outputs.reapable_branches }}
505+
with:
506+
script: |
507+
const owner = context.repo.owner;
508+
const repo = context.repo.repo;
509+
// Re-checked here, not trusted. `sweep` only ever puts prefixed
510+
// branches in the bucket, so a violation is a corrupted hand-off
511+
// rather than a branch to delete anyway — and the cost of being
512+
// wrong in this job is a branch that exists nowhere else.
513+
const PREFIX = 'claude/';
514+
515+
let branches;
516+
try {
517+
branches = JSON.parse(process.env.REAPABLE || '[]');
518+
} catch (err) {
519+
core.setFailed(`the reapable list handed over by \`sweep\` is not JSON -- ${err.message}. Nothing was deleted.`);
520+
return;
521+
}
522+
if (!Array.isArray(branches)) {
523+
core.setFailed('the reapable list handed over by `sweep` is not an array. Nothing was deleted.');
524+
return;
525+
}
526+
const stray = branches.filter((b) => typeof b !== 'string' || !b.startsWith(PREFIX));
527+
if (stray.length > 0) {
528+
core.setFailed(
529+
`the reapable list contains ${stray.length} entry/entries outside \`${PREFIX}\`: ` +
530+
`${stray.join(', ')}. Nothing was deleted -- a hand-off this wrong is not one to act on partially.`,
531+
);
532+
return;
533+
}
534+
if (branches.length === 0) {
535+
core.info(`The sweep classified nothing as reapable. Nothing to delete.`);
536+
return;
537+
}
538+
539+
// The audit trail the ruling asks for: the WHOLE list, in the run
540+
// log, before the first deletion — so a run that dies halfway still
541+
// leaves behind what it intended to do.
542+
core.info(`Deleting ${branches.length} branch(es) classified \`reapable\` by the sweep job:`);
543+
for (const branch of branches) core.info(` - ${branch}`);
544+
545+
const deleted = [];
546+
const alreadyGone = [];
547+
const failed = [];
548+
for (const branch of branches) {
549+
core.info(`deleting ${branch}`);
550+
try {
551+
await github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` });
552+
deleted.push(branch);
553+
} catch (err) {
554+
// A ref already gone is not a failure: `delete_branch_on_merge`
555+
// reaps 1385 of every 1386 natively, so losing the race to the
556+
// platform is the EXPECTED outcome, not an error.
557+
if (err.status === 404 || err.status === 422) {
558+
alreadyGone.push(branch);
559+
core.info(` already gone: ${branch}`);
560+
continue;
561+
}
562+
failed.push({ branch, status: err.status, message: err.message });
563+
core.warning(` FAILED ${branch}: ${err.status} ${err.message}`);
564+
}
565+
}
566+
567+
const lines = ['## Merged-branch reaper — DELETION PASS', ''];
568+
lines.push(`Deleted **${deleted.length}**, already gone **${alreadyGone.length}**, failed **${failed.length}**, of ${branches.length} branch(es) the sweep classified \`reapable\`.`);
569+
lines.push('');
570+
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.`);
571+
lines.push('');
572+
for (const b of deleted) lines.push(`- ✅ deleted \`${b}\``);
573+
for (const b of alreadyGone) lines.push(`- • already gone \`${b}\``);
574+
if (failed.length > 0) {
575+
lines.push('');
576+
lines.push(`### ⛔ ${failed.length} deletion(s) failed`);
577+
lines.push('');
578+
for (const f of failed) lines.push(`- \`${f.branch}\` — ${f.status} ${f.message}`);
579+
}
580+
await core.summary.addRaw(lines.join('\n')).write();
581+
582+
core.notice(`Reaped ${deleted.length} of ${branches.length} ${PREFIX} branches (${alreadyGone.length} already gone, ${failed.length} failed).`);
583+
if (failed.length > 0) {
584+
core.setFailed(`${failed.length} branch deletion(s) failed -- see the log for each status.`);
585+
}

0 commit comments

Comments
 (0)