Skip to content

feat(ci): nightly merged-branch prune across the account, dry run by default - #8

Merged
rishitank merged 3 commits into
mainfrom
feat/prune-merged-branches
Oct 9, 2026
Merged

rishitank merged 3 commits into
mainfrom
feat/prune-merged-branches

Conversation

@rishitank

Copy link
Copy Markdown
Owner

Why

Branches from PRs merged before delete_branch_on_merge was switched on stay around forever, and nothing cleans them up. #7 deletes a PR's head branch as it merges, going forward. This PR clears the existing backlog across every repository the account owns, and anything #7 misses later. It uses narrow rules that only ever remove a pointer whose commits are already in the base branch.

This is the same design as TanksterAI/.github#17. scripts/prune-merged-branches.sh is byte-identical in both repos (blob 9360696e).

What changed

  • scripts/prune-merged-branches.sh (new). Depends on gh and jq and fails loudly if either is missing. It walks user/repos?affiliation=owner, filtered to owner rishitank, and paginates everything.
  • .github/workflows/branch-prune.yml (new):
    • Triggers: schedule at 17 4 * * *, and workflow_dispatch with a mode input (dry-run by default, or apply).
    • Runs on ubuntu-latest with permissions: contents: read. Checkout is SHA-pinned with persist-credentials: false. A concurrency group queues runs and never cancels them.
    • The job uploads the branch-prune-report artifact and writes a markdown table to the step summary.

Safety rules (all must hold, or the branch is report-only)

The repository must not be archived and must not be a fork. A branch can be deleted only when all of these hold:

  1. It is not the default branch.
  2. It is not protected.
  3. No ruleset applies a deletion rule to it.
    • A 404, or a plan 403, counts as "no rules".
    • Any other failure means "unknown", and the branch is kept.
  4. It does not match ^(main|master|develop|staging|test|gh-pages|v[0-9][0-9.]*|deploy-.*|release/.*)$.
  5. No OPEN PR uses it as head or base.
  6. A MERGED PR in the same repository has this branch as its head.
  7. The branch's tip equals that PR's head SHA, meaning nothing was pushed after the merge.
  8. The tip is re-read immediately before acting, including in dry-run.

How deletion works in apply mode:

  • It uses DELETE repos/R/git/refs/heads/<urlencoded B>. A 422 or 404 means the branch is already gone.
  • Every outcome is read back.

Report-only categories are keep/protected, open, no-pr, closed-unmerged and merged-but-moved.

When it deletes

Trigger Mode
nightly schedule dry-run, unless the repository variable PRUNE_APPLY is exactly true
workflow_dispatch the mode input; default dry-run
PRUNE_TOKEN secret missing nothing runs: a notice and a step summary explain how to create the token, and the job exits green

How to enable

  1. Create a fine-grained PAT. Set resource owner to rishitank and repository access to All repositories. Give it these repository permissions:
    • Contents: Read and write
    • Pull requests: Read-only
    • Administration: Read-only (needed for branch rules)
    • Metadata: Read-only
  2. Save it as the repository secret PRUNE_TOKEN.
  3. Run branch-prune by hand with mode: dry-run and read the report.
  4. When the report looks right, add the repository variable PRUNE_APPLY = true, or dispatch once with mode: apply.

Checks

  • actionlint is clean on the new workflow, and shellcheck is clean on the script.
  • The script was exercised against a stubbed gh covering every category, both 403 cases, a tip moving mid-run, 422 and 500 on delete, and an unreadable repo.
  • The same script ran a real org-wide dry run on TanksterAI: 36 repos, 19 deletable, matching the branch inventory exactly.

🤖 Generated with Claude Code

https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi

…default

Adds scripts/prune-merged-branches.sh (byte-identical to the copy in
TanksterAI/.github) and .github/workflows/branch-prune.yml, which sweeps
every repository rishitank owns. Deletes only branches merged at their
current tip; everything else is reported. Nightly runs stay dry unless
the PRUNE_APPLY variable is `true`; without the PRUNE_TOKEN secret the
job explains how to create it and exits green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 776ed91a-c7c6-4d03-901a-2a82032bf60c
📥 Commits

Reviewing files that changed from the base of the PR and between 3978d59 and a6a8271.

📒 Files selected for processing (2)
  • .github/workflows/branch-prune.yml
  • scripts/prune-merged-branches.sh

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Recent review details
🔇 Additional comments (5)
scripts/prune-merged-branches.sh (4)

58-63: Option parsing calls fail_usage too late. Validate the value before you shift.

Apprentice, this parser has rebel scum in it. Consider --mode passed with no value. mode="${2:-}" sets mode to empty. shift 2 then fails because only one argument remains. In bash, shift with a count larger than $# returns non-zero and does not shift. fail_usage then reports the correct error, so that path works.

Now consider --report --mode apply. The parser takes --mode as the report path and consumes it. apply then reaches the unknown-argument branch, so the run ends with a usage error. No deletion happens, so this path is safe.

The real risk is --user --mode apply. The parser sets owner to --mode, and apply then fails as an unknown argument. That path is also safe. None of these cases can delete anything by mistake. The only defect is an unclear error message. I withdraw this as an issue.


237-237: PRs whose head repository was deleted are dropped from the open-PR check. Confirm that the merged-PR rule stays fail-safe.

$own keeps only PRs where .head_repo == $repo. Rule 5 builds $open from every PR in $p, so a same-name branch from a fork still blocks deletion. That direction is fail-safe. The power of the Dark Side is in this guard.


116-145: LGTM!


294-311: LGTM!

.github/workflows/branch-prune.yml (1)

85-117: LGTM!


📝 Summary

Summary by CodeRabbit

  • New Features
    • Added scheduled and manually triggered cleanup of eligible merged branches across repositories.
    • Runs default to dry-run; deletions can be enabled for scheduled runs or selected for manual runs.
    • Branches are retained when protected, associated with open pull requests, or when eligibility cannot be verified. Archived repositories and forks are skipped.
    • Cleanup results are provided as a report, with a summary available in the workflow run.

Walkthrough

Adds a scheduled and manually dispatchable workflow to scan repositories for merged branches. The script classifies branches, checks deletion conditions, optionally deletes eligible branches, and writes JSON and Markdown reports.

Changes

Merged branch pruning

Layer / File(s) Summary
Workflow entry and invocation
.github/workflows/branch-prune.yml
Defines scheduled and manual runs, checks for PRUNE_TOKEN, selects a dry-run or apply mode, invokes the script, and uploads the report when available.
Script inputs and repository scan
scripts/prune-merged-branches.sh
Validates inputs and dependencies, selects repositories, and loads branches, pull requests, and commit dates.
Branch eligibility and live checks
scripts/prune-merged-branches.sh
Classifies branches using protection and pull-request data. Before action, checks branch rules and rereads each candidate's ref.
Deletion and run reports
scripts/prune-merged-branches.sh
Records dry-run candidates or deletes eligible refs in apply mode, verifies deletion, and creates JSON and Markdown reports.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Workflow as branch-prune.yml
  participant Script as prune-merged-branches.sh
  participant GitHubAPI as GitHub API
  participant Artifact as Report artifact
  Workflow->>Script: Pass owner, mode, and report path
  Script->>GitHubAPI: List repositories and inspect branch refs
  GitHubAPI-->>Script: Return repository and ref data
  Script->>GitHubAPI: Delete eligible refs in apply mode
  GitHubAPI-->>Script: Return deletion result
  Script-->>Workflow: Write report
  Workflow->>Artifact: Upload report when available
Loading

Merge Risk: ⚪ Minimal · up to a6a82

No actionable issue was established that would prevent merging after normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 1 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the main change: a nightly merged-branch prune with dry-run as the default.
Description check Passed The description directly explains the workflow, script, safety rules, execution modes, setup, and validation for the branch-pruning changes.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 1 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Apprentice, the branches meet the night,
Merged tips are checked before the flight.
A dry-run marks what might depart,
Apply mode acts with guarded art.
Reports record each scanned trail,
The Dark Side logs what did not fail.

Comment @coderabbitai help to get the list of available commands.

rishitank and others added 2 commits October 9, 2026 04:56
A workflow with only schedule and workflow_dispatch triggers cannot be
dispatched until it is on the default branch (the dispatch API returns
404). One pull_request run registers it and exercises the no-token path;
the next commit removes the trigger again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi
The workflow is registered now, so it can be dispatched on this branch.
Back to schedule + workflow_dispatch only: a PR-triggered run would follow
PRUNE_APPLY and could delete from a feature branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi

@rishitank rishitank left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review by Claude (head a6a8271). I read both changed files in full.

scripts/prune-merged-branches.sh
This is blob 9360696e, byte-identical to the copy merged today as TanksterAI/.github#17. That copy has a real apply run (TanksterAI run 37945894623): 36 repos, 19 deleted, 0 failed, 0 unreadable. I reviewed it line by line there:

  • strict --mode;
  • the empty-list refusal;
  • full pagination;
  • merged-at-current-tip with head_repo == R;
  • an open PR as head or base keeps the branch;
  • the rules endpoint: 404 or the plan 403 means none, anything else means keep;
  • @uri-encoded re-read before acting;
  • read-back after DELETE.

--user lists user/repos?affiliation=owner and filters to owner, so collaborator repos are never touched.

.github/workflows/branch-prune.yml

  • Triggers are schedule and workflow_dispatch only. The temporary pull_request trigger used to register the workflow was removed in a6a8271, so a PR run can never follow PRUNE_APPLY.
  • Workflow permissions: {}. The job has contents: read and pins checkout by SHA (3d3c42e = v7.0.1) with persist-credentials: false.
  • upload-artifact is pinned at 043fb46 (= v7.0.1).
  • A queued, non-cancelling concurrency group.
  • Mode:
    • dispatch follows the mode input, which defaults to dry-run;
    • schedule applies only when the PRUNE_APPLY variable is literally true.
  • Without PRUNE_TOKEN, a notice and a step summary with setup steps, and the job exits green. The token is only ever read through env.
  • runs-on: ubuntu-latest is correct for a personal repo: there is no CI_RUNNER rule here.

CI: actionlint, Renovate presets and CodeRabbit are green. There are 0 review threads.

Result: no defects found and no fixes needed.

For Rishi: nothing runs for real until the PRUNE_TOKEN secret exists, created with the permissions listed in the workflow summary.

@rishitank
rishitank merged commit 6cdfd53 into main Oct 9, 2026
4 checks passed
@rishitank
rishitank deleted the feat/prune-merged-branches branch October 11, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant