ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches - #15224
Merged
Merged
Conversation
…aude/ 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
…cturally 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
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
This was referenced Sep 4, 2026
baozhoutao
marked this pull request as ready for review
September 4, 2026 07:02
baozhoutao
enabled auto-merge
September 4, 2026 07:02
zhuangjianguo
pushed a commit
that referenced
this pull request
Sep 4, 2026
…aude/ 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12771
Arms
.github/workflows/merged-branch-reaper.yml: report-only becomes the scheduled weekly deletion the maintainer ruled on 2026-09-04 (comment5536252939, decision batch #30), reaffirming the 2026-08-31 ruling (5472666478) now that the base-ref guard of #13503 has landed in PR #15144.Bucket counts — from the dry run on THIS PR's head, not predicted
Run 33845242372 on
f8080a6c6, produced by this workflow's ownpull_requesttrigger. Quoted from its##[notice]line:main, past grace —reapablemain— base-ref guardThe buckets sum to 384 on live data, so the partition the contract harness asserts under doubles also holds on the real population.
The predicted number for the first armed run is 110.⚠️ It will drift: the sweep ran twice on this PR ten minutes apart and the population moved 383 → 384 while
noPrmoved 209 → 208, because ~140 PRs a day continuously give branches a PR. Ruling item 3 asked for the re-measured legacy set; 110, not the stale 111 of 2026-08-30, is it.⭐ The base-ref guard holds back zero
claude/branches today. #13503 measured 48 of 575 (8.3%) forcopilot/; forclaude/it is 0 of 110. The guard is not doing nothing — it is insurance whose premium is currently zero, and the asymmetry it was bought for (an unreaped branch costs a line of noise; a wrongly reaped one costs work that exists nowhere else) is unchanged.Permissions, per job
GitHub scopes
permissions:per job, so the deletion is a separate job.sweep(classify)reap(delete)contentsreadreadwritepull-requestsreadreadreapconsumesneeds.sweep.outputs.reapable_branchesand callsDELETE /repos/{owner}/{repo}/git/refs/heads/{branch}. It computes nothing: it re-checks theclaude/prefix, prints the whole list, then deletes.⭐ The split is the property, not a formality. The ~140 lines that decide which branches keep the read-only token they were reviewed under — a defect there still cannot delete anything by itself — and the write grant reaches a job that makes no such decision.
The delete job's gating conditions
success()is written, not left implicit (静态扫描:job 级if:读needs.*.outputs.*必须显式带状态函数(#4928 沉淀的规则 + publish-smoke.yml 唯一存量违规) #5343): ifsweepdied there is no classification to act on, and "the classifier failed" must never read the same as "nothing to do".!= 'pull_request'stands as its own clause, redundantly with the allowlist, because it is the one exclusion a reader must find without evaluating the rest. This workflow triggers onpull_requestso that edits exercise the sweep — without the exclusion, editing this file would delete branches on the head of the PR editing it.workflow_dispatchinputdry_run,type: boolean,default: true— declared as an addition to the ruling. It is the fail-closed reading of "workflow_dispatchkept for a manual run": a manual run is for looking at the list; deleting by hand is an explicitdry_run: false.⭐ The
inputscontext on a non-dispatch event is measured, not assumed.reap'sif:readsinputs.dry_run, andinputsis populated only forworkflow_dispatch. The twopull_requestruns are the control: on an event whereinputsis equally absent, the job skipped — it did not error — so the expression resolves rather than raising. And the residual direction is safe anyway: an expression error would fail the job, which deletes nothing and is visible, rather than deleting something quietly.schedulepath'sif:has not run on a real runner — the schedule is weekly, and this seat is forbidden from triggering aworkflow_dispatch. Thepull_requestskip is the closest available control.Verified on a real runner, twice. In runs 33844847316 and 33845242372,
Merged-branch sweep= success andDelete the reapable branches= skipped. ⛔ Noworkflow_dispatchwas ever run from this branch, and nothing has been deleted by this PR's work.Not touched
reapablestill requires a merged PR withbase.ref === 'main', fail-closed on a PR record carrying nobase.PREFIX = 'claude/'—copilot/stays on [finding] copilot/ is the largest branch namespace (678 refs, 2x claude/) and is outside the #12771 reaper scope — unmeasured #13503's own release line.BASE_REF = 'main', the grace window,cron: '37 4 * * 1', and theis-ancestorprohibition with the workflow's reverse check against it.git push --deleteis refused 403, and dead branches accumulate with no reaper #12771 option B stays refused); the grant lives in one job and expires with the run.What the contract harness gained
scripts/check-merged-branch-reaper-outcome.mjsextracts and drives thesweepclassifier, and by construction can say nothing about the job that deletes. Two additions close that gap.sweeppublishesreapable_branches; scenarios G1/G2/R1 assert the list equals the reapable bucket — same members, same order — over a population carrying one branch in every bucket. Mutations M13 (held branches leak into the list) and M14 (the list is never published) drive both directions red.reapFenceFailures()parses the shipped YAML and asserts: theif:excludespull_requestand gatesworkflow_dispatchoninputs.dry_run == false;reapdeclarescontents: writeand is the only job in the file that does; the top-level grant stayscontents: read;reapstillneeds: sweep. New self-test battery 6 drives six mutations of the workflow text to red (F1–F6), each asserting its own anchor was present first.Verification record
Gate union re-run at the final commit,
git rev-parse --short HEADquoted from that run. Exit codes captured by redirect before any pipe (cmd > log 2>&1; EXIT=$?), and every result below is the gate's own verdict line, never a bare$?.41 of 42 derived commands exit 0 at
f8080a6c6, the final commit — the family derived bynode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom a tree atorigin/main(⛔ not recalled, and re-derived after mergingorigin/mainbecause the first derivation printed a STALE TREE warning naming 11 files it derives from).Verdict lines, each printed by the gate itself:
⭐ Non-vacuity:
check-workflow-status-functionsreports 54 jobs across 30 workflows where 53 across 30 existed before it (measured, not assumed: this diff adds exactly one job to one already-tracked workflow file —git show $(git merge-base origin/main HEAD):.github/workflows/merged-branch-reaper.ymlparses to 1 job, HEAD to 2, and no workflow file was added or removed), andcheck-step-collectorsreports 396run:steps across 30 workflow(s). Both therefore demonstrably read the newreapjob rather than passing over an unseen path.The one non-zero, and why it is neither a red nor a pass:
node scripts/check-required-contexts.mjs --verify-required-set— exit 2. Its own first line:required-set sweep: NOT VERIFIED — GET .../repos/objectstack-ai/objectstack answered HTTP 401, and the gate classifies that itself: "NOT VERIFIED is not a pass and not a failure of the tree (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690); exit 2 classifies the ENVIRONMENT." Re-run as the gate instructs —NODE_OPTIONS=--use-env-proxy— it exits 0 and reads the live ruleset: "7 live required context(s) on main, 0 registered-but-not-required, 1 required-but-unpinned." That unpinned one is pre-existing and unrelated to this diff; filed as [finding]Governed Surface Queue Guardis required onmainbut pinned by no REQUIRED_CONTEXTS row — renaming its job detaches the governed-surface gate silently #15233.NODE_OPTIONS=--use-env-proxy,check-cross-package-test-inputs --self-testgoes red onimporting this module prints NOTHING, because Node emits an[UNDICI-EHPA] Warningon the child's stream that the module did not write. Controlled both directions on this tree with the flag as the only variable (exit 0 without, exit 1 with) — so the red is the flag, never the diff. Filed as #15234. This batch therefore ran in the CI-equivalent environment, and the required-set sweep was run once, separately, with the flag it asks for.⭐ An earlier pass over the same 42 commands is discarded rather than quoted: 32 of its 42 logs predate the last write to the tree, so its greens describe a tree that is no longer HEAD. Exit codes throughout are captured by redirect before any pipe (
cmd > log 2>&1; EXIT=$?) — nevercmd | tailthen$?, and never a bare$?in place of the gate's own verdict line.Resource note: the shared verify lock was free and these are
check:*gate scripts, which that lock's own--statustext says it never covers. Declared, not silent. No build or test suite was run: this diff adds no package code.Ablations, run in memory — the tree is never written to, so there is no restore leg to get wrong and no
trapto depend on. Each asserts its anchor was present first and reports the occurrence count.} else if (intoMain.length === 0) {→} else if (false) {) — the #15144 ablation, re-runpull_requestexclusion removed from the workflow textControl leg on the unmutated tree: classifier battery 0 failures over 72 assertions, delete-job fence 0 failures.
The
reapscript driven under doubles (extracted from the shipped YAML, run asactions/github-scriptruns it; in memory, 12/12 pass): deletes exactly the handed list and nothing else · logs the whole list before the first deletion · an empty or unset hand-off deletes nothing and is not an error · non-JSON and non-array hand-offs fail closed with zero deletions · one entry outsideclaude/aborts the whole pass rather than partially reaping · a ref already gone (404/422) is not a failure, becausedelete_branch_on_mergewins that race 1385 times in 1386 · a real refusal (403) turns the job red rather than reporting a silent no-op.Control-byte scan of both edited files and of every body posted to GitHub: clean (
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', exit 1 = no hits).skip-changesetapplied via the additive endpoint (POST .../issues/15224/labels, HTTP 200) and read back — the diff publishes nothing from any released package.Declared limitation: the run's uploaded artifact and its raw job log both live on
productionresultssa6.blob.core.windows.net, which this container's egress proxy refuses (connect_rejected, organization policy). The bucket counts above therefore come from the run's##[notice]line — which is why this PR also puts every excluded bucket on that line, where the previous version named only three of seven. The run log is the audit trail the ruling names; it now answers "what did it hold back, and why" without opening the artifact.Generated by Claude Code