You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
lines.push('## Merged-branch reaper — DRY RUN. Nothing was deleted.');
396
+
lines.push('## Merged-branch reaper — classification. THIS JOB deletes nothing.');
322
397
lines.push('');
323
398
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.`);
325
400
lines.push('');
326
401
lines.push(`### Population: ${total} \`${PREFIX}\` branches (of ${allBranches.length} on the remote)`);
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\`.`,
0 commit comments