feat(ci): dead-branch sweeper (dry-run by default, merged-only delete) - #6
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
PR Analysis SummaryChange Metrics
Risk factors:
Classification
Files by Type
💡 Recommendations
Analyzed by Woden Tagger • Automated PR insights for better code reviews
💡 Want enhanced Github Projects/Notes and to turn Issues into a Service Desk? Check out Woden Desk on the GitHub Marketplace |
Central, org-wide job (register-skill.yml's shape, not a per-repo workflow_call) that deletes only branches fully merged into their repo's default branch — checked both by commit-containment and by merged-PR history, so squash/rebase merges are still recognised as merged. Never touches the default branch, the mnab set (main/next/before/after), anything GitHub reports as protected, or any branch with an open PR. Unmerged branches idle > stale_days (default 90) with no open PR are report-only in the job summary + JSON artifact — the "stalled but maybe-real work" signal the legibility doctrine says to surface, not erase. Ships inert: scheduled runs stay dry-run unless the operator sets BRANCH_SWEEP_ARMED=true; manual dispatch also defaults dry_run=true. Token model documented in docs/branch-sweep.md: interim bot-owned fine-grained PAT (Contents: write, Pull requests: read, least privilege), target-state migration path to the already-decided-but-unregistered aifred-maintenance App noted for when that App exists. Governance note: no DECISIONS.md entry currently authorizes org-wide auto-delete of merged branches. This PR does not add one — it ships inert (dry-run) and flags that a short DECISIONS.md post should land before BRANCH_SWEEP_ARMED is ever set to true anywhere. See docs/branch-sweep.md "Governance note" section. Shellcheck-clean (scripts/dead-branch-sweep.sh), YAML-validated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGpSmSMHtG3rP13yq5sU1g
Security review (inquisitor) + evidence pass (referent) found blockers on PR#6 before merge, fixed here: - F1 [CRITICAL] data-loss: has_merged_pr only counted a closed PR's merged_at, matched by branch NAME. GitHub keeps closed-PR records forever, so a reused branch name (force-pushed with new unmerged commits after an earlier PR on that name merged, or deleted+recreated) would still read as merged via the stale record even though compare correctly reported diverged -> genuinely unmerged work could get deleted. Now requires the merged PR's head.sha to equal the branch's current tip before trusting it; a genuine squash/rebase merge of the current tip still matches, since the tip hasn't moved since GitHub recorded it. - F2 [HIGH] query-param injection: branch names can legally contain '&'/'='/'#' (git ref rules don't forbid them) and were spliced raw into `pulls?head=...&state=all`, so a crafted branch name could inject a second head= param and read a different branch's PR history. Switched to `gh api -f head=... -f state=all` (gh encodes the query for us) and percent-encode every branch name used in a REST path segment (branches/<name>, compare/<base>...<name>, git/refs/heads/<name>). - F5 [MED] SHA-pin actions/checkout + actions/upload-artifact in dead-branch-sweep.yml. Repo convention (72f78e9) deliberately leaves first-party actions/* on floating tags; this workflow is an explicit exception because the Sweep step shares a job with BRANCH_SWEEP_TOKEN (org-wide contents:write) -- noted inline in the workflow why this one diverges from the general convention. - Reconciled against current main (referent's regression finding): PR#6's branch predated main's CodeQL "Code injection" fix to mnab-gate.yml (build-command allowlist). Rebased onto origin/main -- git recognised the branch's own mnab-gate.yml/pin-SHA/light-gates-docs commits as already-applied-equivalent patches and dropped them automatically, so the only real conflict was a README.md table-row merge. Diffed clean afterward: dead-branch-sweep's diff against main is now only the three new sweep files, no stale mnab-gate.yml copy. docs/branch-sweep.md: added a second-gate note -- BRANCH_SWEEP_ARMED must also confirm the head.sha-match fix is still present in the script, not just that a DECISIONS.md authorization entry exists (F1 fix must stay landed, per inquisitor O1). Does not merge -- operator's review gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGpSmSMHtG3rP13yq5sU1g
003a4eb to
ae4c884
Compare
PR Analysis SummaryChange Metrics
Risk factors:
Classification
Files by Type
💡 Recommendations
Analyzed by Woden Tagger • Automated PR insights for better code reviews
💡 Want enhanced Github Projects/Notes and to turn Issues into a Service Desk? Check out Woden Desk on the GitHub Marketplace |
|
Security review fixes pushed (F1/F2/F5 from inquisitor's review + referent's evidence pass):
Not merging — this stays on the operator's review gate. |
|



Summary
Central, org-wide dead-branch sweeper (
dead-branch-sweep.yml+scripts/dead-branch-sweep.sh+docs/branch-sweep.md), operator directive:"vi ska ha en sweeper på döda branches."
together: commit-containment (
compare base...branch= identical/behind)and merged-PR history (closed PR with
merged_atset) — the secondcheck exists specifically because squash/rebase merges break plain
commit-containment detection.
(
main/next/before/after), anything GitHub itself reports asprotected, or any branch with an open PR — regardless of merge state.not merged + idle >
stale_days(default 90) → listed in the job summaryand a JSON artifact for triage. An unmerged branch with no PR is the
"stalled but maybe-real-work" signal, not garbage — per the legibility
doctrine.
the operator sets a
BRANCH_SWEEP_ARMED=truerepo/org variable — aseparate, deliberate step outside this PR. Manual
workflow_dispatchalsodefaults
dry_run: true.BRANCH_SWEEP_TOKENsecret):Contents: writePull requests: readonly. Scope note indocs/branch-sweep.md: thisonly reaches repos the fleet-owned token can see —
alfred-intelligenceonly, not
GeGGe01/SAVANTERNA/kebab-it.already-decided-but-not-yet-registered
aifred-maintenanceApp(
.github-private/strategy/meta-apps.md). Documented as the migrationpath once that App exists; not blocking this PR.
Governance conformance
Checked
.github-private/DECISIONS.md(current throughbca3b2a) beforebuilding: no existing entry authorizes org-wide auto-delete of merged
branches. This PR does not add that DECISIONS.md entry — it ships the
mechanism dry-run/inert and flags in
docs/branch-sweep.md("Governancenote") that a short DECISIONS.md post should land before anyone ever sets
BRANCH_SWEEP_ARMED=true. Does not fight the mnab CI-gate-gradient orbranch-protection config — never touches protected branches or required
checks.
Setup needed before first real (non-dry-run) use
BRANCH_SWEEP_TOKEN(fine-grained PAT, bot-owned,Contents: R+WPull requests: R, all-repos-in-org) — seedocs/branch-sweep.md.workflow_dispatchwithdry_run: true(default) andeyeball the job summary.
BRANCH_SWEEP_ARMED=true— and only after thegovernance note above is addressed.
Test plan
python3 -c "import yaml; yaml.safe_load(...)"— workflow YAML validshellcheck scripts/dead-branch-sweep.sh— cleanBRANCH_SWEEP_TOKEN, runworkflow_dispatchdry-runagainst a small
repos_overridelist, verify job summary outputbefore trusting an org-wide dry-run
docs/branch-sweep.mdgovernance note, decide on aDECISIONS.md entry before arming
🤖 Generated with Claude Code
https://claude.ai/code/session_01JGpSmSMHtG3rP13yq5sU1g