Skip to content

ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches - #15224

Merged
baozhoutao merged 4 commits into
mainfrom
claude/issue-12771-arm-claude-reaper
Sep 4, 2026
Merged

ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches#15224
baozhoutao merged 4 commits into
mainfrom
claude/issue-12771-arm-claude-reaper

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #12771

Arms .github/workflows/merged-branch-reaper.yml: report-only becomes the scheduled weekly deletion the maintainer ruled on 2026-09-04 (comment 5536252939, 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 own pull_request trigger. Quoted from its ##[notice] line:

Sweep: 110 of 384 claude/ branches are reapable and are handed to the reap job. Report-only, never reaped: 0 merged somewhere other than main and held by the base-ref guard; 208 have no PR and are unreachable by this criterion; 20 have an open PR; 46 closed unmerged; 0 merged within the 7-day grace window; 0 protected. This job deleted nothing — its token grant is contents: read.

bucket count share first armed run
MERGED into main, past grace — reapable 110 28.6% deleted
MERGED, not into main — base-ref guard 0 0.0% ⛔ report-only
MERGED, within the 7-day grace window 0 0.0% ⛔ report-only
OPEN PR 20 5.2% ⛔ report-only — in use
CLOSED, unmerged 46 12.0% ⛔ report-only — MERGED-only policy
NO PR at all 208 54.2% ⛔ unreachable by construction
protected 0 0.0% ⛔ report-only
population 384 of 1133 heads on the remote

The 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 noPr moved 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%) for copilot/; for claude/ 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.

top-level default sweep (classify) reap (delete)
contents read read write
pull-requests read read — not granted
deletes a ref? structurally cannot yes, and only this job
classification decisions all of them none

reap consumes needs.sweep.outputs.reapable_branches and calls DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}. It computes nothing: it re-checks the claude/ 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

if: >-
  success()
  && github.event_name != 'pull_request'
  && (github.event_name == 'schedule'
  || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false))
  • success() is written, not left implicit (静态扫描:job 级 if:needs.*.outputs.* 必须显式带状态函数(#4928 沉淀的规则 + publish-smoke.yml 唯一存量违规) #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' 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 on pull_request so that edits exercise the sweep — without the exclusion, editing this file would delete branches on the head of the PR editing it.
  • Then an allowlist of the two armed paths, so a trigger added later is dry by default and has to be armed on purpose.
  • New workflow_dispatch input dry_run, type: boolean, default: true — declared as an addition to the ruling. It is the fail-closed reading of "workflow_dispatch kept for a manual run": a manual run is for looking at the list; deleting by hand is an explicit dry_run: false.

The inputs context on a non-dispatch event is measured, not assumed. reap's if: reads inputs.dry_run, and inputs is populated only for workflow_dispatch. The two pull_request runs are the control: on an event where inputs is 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.

⚠️ Declared as not exercised: the schedule path's if: has not run on a real runner — the schedule is weekly, and this seat is forbidden from triggering a workflow_dispatch. The pull_request skip is the closest available control.

Verified on a real runner, twice. In runs 33844847316 and 33845242372, Merged-branch sweep = success and Delete the reapable branches = skipped. ⛔ No workflow_dispatch was ever run from this branch, and nothing has been deleted by this PR's work.

Not touched

What the contract harness gained

scripts/check-merged-branch-reaper-outcome.mjs extracts and drives the sweep classifier, and by construction can say nothing about the job that deletes. Two additions close that gap.

  1. The hand-off is pinned. sweep publishes reapable_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.
  2. The delete job is fenced structurally. New reapFenceFailures() parses the shipped YAML and asserts: the if: excludes pull_request and gates workflow_dispatch on inputs.dry_run == false; reap declares contents: write and is the only job in the file that does; the top-level grant stays contents: read; reap still needs: 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 HEAD quoted 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 by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack from a tree at origin/main (⛔ not recalled, and re-derived after merging origin/main because the first derivation printed a STALE TREE warning naming 11 files it derives from).

Verdict lines, each printed by the gate itself:

$ node scripts/check-merged-branch-reaper-outcome.mjs
  check-merged-branch-reaper-outcome: OK (72 assertions over 11 scenarios, driving the 9847-char classifier extracted from .github/workflows/merged-branch-reaper.yml).
$ node scripts/check-merged-branch-reaper-outcome.mjs --self-test
  ✓ check-merged-branch-reaper-outcome --self-test: 92 assertions, 14 mutations of the shipped script each driven to red.
$ pnpm check:workflow-status-functions
  check-workflow-status-functions: OK (scanned 30 workflow file(s), 54 job(s), 27 job-level if: expression(s); 10 read needs.*.outputs.*, all naming a status function).
$ pnpm check:required-contexts
  ✓ check-required-contexts: 6 required context name(s) pinned across 2 workflow(s); 5 instruction surface(s) scanned against 2 retired name(s) (#9491).
$ node scripts/check-step-collectors.mjs
  ✓ check-step-collectors: 396 `run:` steps across 30 workflow(s); 4 step(s) run 2+ independent self-tests, all of them through a collector.
$ node scripts/check-ci-filter-parity.mjs
  OK: all 143 declared cross-package glob(s) (99 unique) are covered by `core` or `crosspkg`, every `crosspkg` entry still covers one, and the `test` job's `if:` still names both filters.
$ pnpm check:nul-bytes
  check-nul-bytes: OK (scanned 8269 text file(s) -- 8269 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).
$ pnpm check:declared-population-live
  ✓ check:declared-population-live — 201 of 252 famil(ies) declare a path population, and every one of them reaches this tree's 8276 tracked file(s).
$ pnpm check:pm-dispatch-gates
  ✓ dispatch-gates self-test: 1353 cases pass.
$ node scripts/check-self-test-wired.mjs
  ✓ check-self-test-wired: every one of the 168 script(s) CI runs that ship a `--self-test` has that self-test run by CI.

Non-vacuity: check-workflow-status-functions reports 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.yml parses to 1 job, HEAD to 2, and no workflow file was added or removed), and check-step-collectors reports 396 run: steps across 30 workflow(s). Both therefore demonstrably read the new reap job rather than passing over an unseen path.

The one non-zero, and why it is neither a red nor a pass:

⚠️ That flag is not carried across the rest of the batch, and the reason is a trap worth naming: under NODE_OPTIONS=--use-env-proxy, check-cross-package-test-inputs --self-test goes red on importing this module prints NOTHING, because Node emits an [UNDICI-EHPA] Warning on 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=$?) — never cmd | tail then $?, 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 --status text 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 trap to depend on. Each asserts its anchor was present first and reports the occurrence count.

ablation anchor result
A1 the base-ref guard removed (} else if (intoMain.length === 0) {} else if (false) {) — the #15144 ablation, re-run present, 1 occurrence RED, 4 failures, caught by G2/G3/G4/R1
A2 the delete job's pull_request exclusion removed from the workflow text present, 1 occurrence RED, 1 failure, named by the fence
A3 control: the classifier battery is blind to the delete job by construction (72 assertions, 0 failures) that blindness is why battery 6 exists

Control leg on the unmutated tree: classifier battery 0 failures over 72 assertions, delete-job fence 0 failures.

The reap script driven under doubles (extracted from the shipped YAML, run as actions/github-script runs 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 outside claude/ aborts the whole pass rather than partially reaping · a ref already gone (404/422) is not a failure, because delete_branch_on_merge wins 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-changeset applied 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

…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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
…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
@baozhoutao
baozhoutao marked this pull request as ready for review September 4, 2026 07:02
@baozhoutao
baozhoutao enabled auto-merge September 4, 2026 07:02
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit d4d5646 Sep 4, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-12771-arm-claude-reaper branch September 4, 2026 07:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] agent seats cannot delete their own remote branches — git push --delete is refused 403, and dead branches accumulate with no reaper

2 participants