diff --git a/.github/workflows/merged-branch-reaper.yml b/.github/workflows/merged-branch-reaper.yml index d4752c72b43..743a6955352 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 @@ -318,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(''); @@ -365,10 +440,23 @@ 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))); + // 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 +467,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.`); + } 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;