From 7a52eae35b3673117ffb537fcf4114e06a8d4b25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:29:39 +0000 Subject: [PATCH 1/3] ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- .github/workflows/merged-branch-reaper.yml | 254 ++++++++++++++++++--- 1 file changed, 226 insertions(+), 28 deletions(-) diff --git a/.github/workflows/merged-branch-reaper.yml b/.github/workflows/merged-branch-reaper.yml index d4752c72b43..05631620e97 100644 --- a/.github/workflows/merged-branch-reaper.yml +++ b/.github/workflows/merged-branch-reaper.yml @@ -1,13 +1,46 @@ -name: Merged-Branch Reaper (report-only) +name: Merged-Branch Reaper # The standing sweep for #12771: `claude/*` branches that outlived the PR they # were the head of. # -# ⛔⛔ THIS WORKFLOW DELETES NOTHING. It classifies and reports. The deleting -# mode is deliberately ABSENT, not merely switched off — see "Enabling the -# deleting mode" at the bottom of this header. The maintainer's ruling of -# 2026-08-28 requires that the first delivery produce the would-delete list for -# one human look BEFORE deletion is ever enabled. +# ⚠️⚠️ 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`. # @@ -112,25 +145,44 @@ name: Merged-Branch Reaper (report-only) # NEW RULING (what proves such a branch is abandoned rather than in flight?), # not an implementation detail, and is deliberately NOT taken here. # -# ## Report-only is enforced by the TOKEN, not just by the code -# -# `permissions: contents: read` below is the whole grant. Deleting a ref needs -# `contents: write`. So even a defect in the classification cannot delete a -# branch: the token this job runs with is structurally incapable of it. That is -# the property to preserve when reviewing changes to this file. -# -# ## Enabling the deleting mode (NOT done here — the maintainer's call) -# -# Three edits, deliberately left undone so that enabling is a reviewed diff and -# not a flipped default: -# 1. raise `permissions:` to `contents: write`; -# 2. add a step calling `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}` -# over the `reapable` list this job already computes and uploads; -# 3. decide the CLOSED-unmerged policy. The default here is MERGED-ONLY. -# Reaping closed-but-unmerged branches discards work that was never -# merged — that is the maintainer's call to make, not this workflow's to -# assume. -# The grace period below should stay in place when that happens. +# ## 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 # @@ -172,6 +224,16 @@ on: 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 @@ -180,9 +242,12 @@ on: paths: - '.github/workflows/merged-branch-reaper.yml' -# Least privilege, and load-bearing: see "Report-only is enforced by the TOKEN". -# `contents: read` lists branches; `pull-requests: read` reads PR state. Neither -# can delete a ref. +# 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 @@ -200,6 +265,16 @@ jobs: 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 @@ -369,6 +444,13 @@ jobs: 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 @@ -379,3 +461,119 @@ jobs: 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.`); + } From 056ebb96709d07f5a8b3ed64ba42b041dcd98d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:33:08 +0000 Subject: [PATCH 2/3] test(reaper): pin the deletion hand-off and fence the delete job structurally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- .../check-merged-branch-reaper-outcome.mjs | 188 +++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/scripts/check-merged-branch-reaper-outcome.mjs b/scripts/check-merged-branch-reaper-outcome.mjs index ab1b7bb5d11..2823c615ab9 100644 --- a/scripts/check-merged-branch-reaper-outcome.mjs +++ b/scripts/check-merged-branch-reaper-outcome.mjs @@ -127,10 +127,11 @@ const SELF_TEST_BATTERY_FLOOR = 5; const UNATTRIBUTED_BATTERY = '(no battery open)'; const SELF_TEST_BATTERIES = Object.freeze({ '1. The unmutated shipped script must be green -- otherwise every red below': 1, - '2. Every mutation must be REACHED and must turn the battery red, in the': 57, + '2. Every mutation must be REACHED and must turn the battery red, in the': 67, '3. A script that does not compile is caught before any scenario runs.': 1, '4. Missing input is a failure, never a pass (#4690).': 1, '5. Wiring. A check nobody runs is the #4449 shape this repo keeps paying': 3, + '6. The DELETE job is fenced structurally -- which events reach it, and': 19, }); function repoRoot() { @@ -181,6 +182,109 @@ export function extractScript(root) { return { source, problems }; } +// ── The delete job's fence (#12771, armed 2026-09-04) ────────────────────── +// +// The scenario battery in this file drives the `sweep` script and can say +// NOTHING about the job that deletes. That job runs no classifier: it consumes +// the list `sweep` publishes and calls `git.deleteRef`. Deletion is kept out of +// the extracted script deliberately -- what this harness judges stays a +// classification rather than an action -- and the price of that is that the +// deleter's safety is entirely STRUCTURAL: which events reach it, and which +// token it holds. So it is asserted structurally, on the shipped bytes, and +// every assertion is driven to red by a mutation of those bytes (battery 6). +// +// ⛔ The clause that matters most is the `pull_request` exclusion. This +// workflow triggers on `pull_request` so that edits to it exercise the sweep +// before merging -- which means that without the exclusion, EDITING THIS FILE +// BECOMES DELETING BRANCHES, on the head of the very PR that edits it. Nothing +// in a YAML diff makes that visible; this does. + +const REAP_JOB = 'reap'; + +/** + * Structural facts about the delete job, read out of the workflow TEXT rather + * than from a path, so the self-test can mutate the text and re-read it. + * + * @param {string} text the workflow file's contents + */ +export function inspectReapJob(text) { + const doc = parseDocument(text); + if (doc.errors.length > 0) return { parsed: false, reason: `YAML parse error -- ${doc.errors[0].message}` }; + if (!isMap(doc.getIn(['jobs']))) return { parsed: false, reason: 'no `jobs:` map' }; + const scalar = (path) => { + const v = doc.getIn(path); + return v == null ? null : String(v); + }; + const jobIds = doc.getIn(['jobs']).items.map((item) => String(item.key)); + return { + parsed: true, + present: isMap(doc.getIn(['jobs', REAP_JOB])), + if: scalar(['jobs', REAP_JOB, 'if']) ?? '', + needs: scalar(['jobs', REAP_JOB, 'needs']), + contents: scalar(['jobs', REAP_JOB, 'permissions', 'contents']), + topLevelContents: scalar(['permissions', 'contents']), + contentsWriteJobs: jobIds.filter((id) => scalar(['jobs', id, 'permissions', 'contents']) === 'write'), + }; +} + +/** + * The fence stated as what is BROKEN. An empty array means it holds. + * + * @param {string} text the workflow file's contents + * @returns {string[]} + */ +export function reapFenceFailures(text) { + const r = inspectReapJob(text); + if (!r.parsed) return [`${WORKFLOW} does not parse -- ${r.reason}`]; + if (!r.present) { + return [ + `${WORKFLOW}: job \`${REAP_JOB}\` is gone. This fence describes the job that DELETES; if deletion moved, ` + + 'it moved somewhere nothing asserts, which is the state this fence exists to prevent.', + ]; + } + const f = []; + if (!r.if.includes("github.event_name != 'pull_request'")) { + f.push( + `${WORKFLOW}: job \`${REAP_JOB}\` no longer excludes \`pull_request\` in its \`if:\` ` + + `(got: ${r.if || 'no `if:` at all'}). This workflow triggers on \`pull_request\` so that edits exercise ` + + 'the sweep -- without this exclusion, editing this file deletes branches, on the head of the PR that edits it.', + ); + } + if (!r.if.includes('inputs.dry_run == false')) { + f.push( + `${WORKFLOW}: job \`${REAP_JOB}\` no longer gates \`workflow_dispatch\` on \`inputs.dry_run == false\`. ` + + 'That input defaults to TRUE; dropping the check turns every manual run into a deleting run, which is the ' + + 'opposite of the fail-closed reading the ruling asks for.', + ); + } + if (r.contents !== 'write') { + f.push( + `${WORKFLOW}: job \`${REAP_JOB}\` does not declare \`permissions: contents: write\` (got ` + + `${r.contents ?? 'nothing'}) -- it cannot delete a ref, so the reaper is armed in prose only.`, + ); + } + if (r.topLevelContents !== 'read') { + f.push( + `${WORKFLOW}: the top-level \`permissions.contents\` is \`${r.topLevelContents ?? 'unset'}\`, not \`read\`. ` + + 'The write grant must stay a local override on the one job that deletes, never the default every job inherits.', + ); + } + if (r.contentsWriteJobs.join(',') !== REAP_JOB) { + f.push( + `${WORKFLOW}: the jobs holding \`contents: write\` are [${r.contentsWriteJobs.join(', ')}], expected exactly ` + + `[${REAP_JOB}]. The classifier must keep the read-only token it was reviewed under -- a defect in ` + + 'classification can then still not delete anything by itself.', + ); + } + if (r.needs !== 'sweep') { + f.push( + `${WORKFLOW}: job \`${REAP_JOB}\` no longer declares \`needs: sweep\` (got ${r.needs ?? 'nothing'}) -- ` + + 'it would run without the classification it exists to consume.', + ); + } + return f; +} + // ── Doubles ───────────────────────────────────────────────────────────────── /** @@ -408,6 +512,10 @@ const SCENARIOS = [ t(bucket(r, 'mergedElsewhere').length === 0, 'G1 leaves the held bucket empty'), t((r.payload?.buckets?.reapable?.[0] ?? {}).base_pr === 101, 'G1 records WHICH pull request cleared the base-ref guard'), t(r.log.outputs.reapable === '1', `G1 reports one reapable branch on the step output, got ${r.log.outputs.reapable}`), + t( + r.log.outputs.reapable_branches === JSON.stringify(['claude/landed-on-main']), + `G1 publishes the machine-readable deletion list the \`reap\` job consumes, and it is exactly the reapable bucket, got ${r.log.outputs.reapable_branches}`, + ), ], }, { @@ -429,6 +537,10 @@ const SCENARIOS = [ t(r.summary().includes('claude/stacked-on-a-sibling'), 'G2 is NAMED in the report -- a held branch nobody can see is a branch excluded in silence'), t(r.summary().includes('claude/the-base'), 'G2 names the base in the report, which is what a human needs to clear it'), t(r.log.outputs.merged_elsewhere === '1', `G2 reports the held count on the step output, got ${r.log.outputs.merged_elsewhere}`), + t( + r.log.outputs.reapable_branches === '[]', + `G2 publishes an EMPTY deletion list -- a branch held by the base-ref guard must never reach the job that DELETES, and the guard is worth nothing if the hand-off leaks past it. Got ${r.log.outputs.reapable_branches}`, + ), t((r.log.notice[0] ?? '').includes('base-ref guard'), 'G2 says on the run notice that a branch was held by the guard'), ], }, @@ -603,6 +715,14 @@ const SCENARIOS = [ t(r.summary().includes('claude/r-held'), 'R1 names the held branch in the excluded section'), t(r.summaryWritten(), 'R1 writes the job summary'), t(r.log.failed.length === 0, `R1 does not fail the job over findings, got ${r.log.failed.join(' | ')}`), + t( + r.log.outputs.reapable_branches === JSON.stringify(['claude/r-reapable']), + `R1 publishes a deletion list of exactly the reapable bucket over a population with one branch in EVERY bucket, got ${r.log.outputs.reapable_branches}`, + ), + t( + JSON.parse(r.log.outputs.reapable_branches || 'null')?.join(',') === bucket(r, 'reapable').join(','), + 'R1: the published deletion list EQUALS the reapable bucket -- same members, same order. This is the contract the `reap` job rests on: it classifies nothing, so what deletes is precisely what this list says.', + ), ], }, ]; @@ -859,6 +979,20 @@ const MUTATIONS = [ to: 'if (false) {', expect: ['P4'], }, + { + id: 'M13', + what: 'the deletion list stops being the reapable bucket -- held branches leak into what `reap` deletes', + from: 'JSON.stringify(buckets.reapable.map((r) => r.branch))', + to: 'JSON.stringify(buckets.reapable.concat(buckets.mergedElsewhere).map((r) => r.branch))', + expect: ['G2', 'R1'], + }, + { + id: 'M14', + what: 'the deletion list stops being published at all, so `reap` consumes an empty hand-off and silently reaps nothing', + from: "core.setOutput('reapable_branches',", + to: "void ('reapable_branches',", + expect: ['G1', 'R1'], + }, ]; // Returned by `selfTest()` only after its verdict is printed. The dispatch @@ -933,6 +1067,58 @@ async function selfTest() { assert(body.includes(`${SELF} --self-test`), `wiring: ${LINT_WORKFLOW} runs the --self-test half too`); } + battery('6. The DELETE job is fenced structurally -- which events reach it, and'); + const wfText = readFileSync(join(root, WORKFLOW), 'utf8'); + const cleanFence = reapFenceFailures(wfText); + assert(cleanFence.length === 0, `the shipped delete job's fence holds, got: ${cleanFence.join(' | ')}`); + // Each mutation asserts its own anchor was PRESENT first: a substitution that + // matched nothing leaves the fence green and reads exactly like a pass. + const FENCE_MUTATIONS = [ + { + id: 'F1', + what: 'the `pull_request` exclusion is dropped, so editing this workflow deletes branches on the PR head', + from: "\n && github.event_name != 'pull_request'", + to: '', + }, + { + id: 'F2', + what: 'the manual path stops honouring the `dry_run` default, so every workflow_dispatch deletes', + from: ' && inputs.dry_run == false', + to: '', + }, + { + id: 'F3', + what: 'the delete job loses its write grant, so the reaper is armed in prose only', + from: ' permissions:\n contents: write', + to: ' permissions:\n contents: read', + }, + { + id: 'F4', + what: 'the write grant is raised to the top level, where the classifier inherits it too', + from: '\npermissions:\n contents: read\n', + to: '\npermissions:\n contents: write\n', + }, + { + id: 'F5', + what: 'the delete job is renamed, so this fence describes a job that no longer exists', + from: '\n reap:\n', + to: '\n reaper:\n', + }, + { + id: 'F6', + what: 'the delete job stops depending on the classification it consumes', + from: ' needs: sweep\n', + to: '', + }, + ]; + for (const m of FENCE_MUTATIONS) { + assert(wfText.includes(m.from), `${m.id}: its anchor is present in the shipped workflow (a no-op mutation proves nothing)`); + if (!wfText.includes(m.from)) continue; + const mutated = wfText.split(m.from).join(m.to); + assert(mutated !== wfText, `${m.id}: the substitution changed the workflow text`); + assert(reapFenceFailures(mutated).length > 0, `${m.id}: ${m.what} -- the fence goes RED`); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); let floorBreached = false; From f8080a6c6970690ee84b12c425ac37afb66421c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:38:50 +0000 Subject: [PATCH 3/3] ci(reaper): put every excluded bucket on the run-log audit line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- .github/workflows/merged-branch-reaper.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/merged-branch-reaper.yml b/.github/workflows/merged-branch-reaper.yml index 05631620e97..743a6955352 100644 --- a/.github/workflows/merged-branch-reaper.yml +++ b/.github/workflows/merged-branch-reaper.yml @@ -393,10 +393,10 @@ jobs: arr.slice(0, k).map(fmt).join('\n') + (arr.length > k ? `\n_...and ${arr.length - k} more_` : ''); const lines = []; - lines.push('## Merged-branch reaper — DRY RUN. Nothing was deleted.'); + 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.`); + 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(''); @@ -440,7 +440,13 @@ jobs: 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(`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.`); + 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)));