Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); ci: add a report-only merged-branch reaper sweep by claude[bot] · Pull Request #13500 · objectstack-ai/objectstack · GitHub
Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

ci: add a report-only merged-branch reaper sweep#13500
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_mergetrue
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-301386
...whose head branch is still on the remote1
native leak rate0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucketcountsharereaped?
MERGED11133.1%✅ would delete
NO PR at all17050.7%unreachable by construction
CLOSED, unmerged4312.8%⛔ excluded — MERGED-only default
OPEN113.3%⛔ excluded — in use

The no-PR bucket is not a stale tail, it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

check:agent-test-spelling check:node-version check:pnpm-acquisition
check:pnpm-filter-targets check:required-contexts check:shard-attestation
check:stall-guard-budget check:workflow-status-functions
check-aggregator-roster check-self-test-wired check-self-test-workflow-commands
check-shard-attestation check-step-collectors check-whole-set-label-write
check-required-contexts docs-audit/check-drift-comment pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries the marker the gate itself provides:

# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run

That is the mechanism's intended use, not a way of making the gate stop asking: its own docblock argues against a hardcoded exemption list in the script and for a marker the workflow carries, "read fresh every run". No assertion was weakened, skipped or special-cased.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.
The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.
Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.
Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-project-manager@claude