Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/cleanup-branches.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Cleanup Merged PR Branches

on:
# Auto-delete head branch whenever a PR is merged
pull_request:
types: [closed]
# Manual trigger to bulk-delete all existing merged-PR branches
workflow_dispatch:

permissions:
contents: write

jobs:
cleanup-on-merge:
if: github.event_name == 'pull_request' && github.event.pull_request.merged == true
name: Delete branch on PR merge
runs-on: ubuntu-latest
steps:
- name: Delete head branch
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const branch = context.payload.pull_request.head.ref;
const owner = context.repo.owner;
const repo = context.repo.repo;

// Never delete protected branches
const protectedBranches = ['main', 'develop', 'master'];
if (protectedBranches.includes(branch)) {
console.log(`Skipping protected branch: ${branch}`);
return;
}

try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branch}`,
});
console.log(`Deleted branch: ${branch}`);
} catch (err) {
if (err.status === 422) {
console.log(`Branch already deleted: ${branch}`);
} else {
throw err;
}
}

bulk-cleanup:
if: github.event_name == 'workflow_dispatch'
name: Bulk delete all merged-PR branches
runs-on: ubuntu-latest
steps:
- name: Delete branches for merged PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;

const protectedBranches = new Set(['main', 'develop', 'master']);

// Collect all head branches from merged PRs (paginate through all pages)
const mergedBranches = new Set();
for await (const response of github.paginate.iterator(
github.rest.pulls.list,
{ owner, repo, state: 'closed', per_page: 100 }
)) {
for (const pr of response.data) {
if (pr.merged_at) {
mergedBranches.add(pr.head.ref);
}
}
}

// Fetch all current branches
const existingBranches = new Set();
for await (const response of github.paginate.iterator(
github.rest.repos.listBranches,
{ owner, repo, per_page: 100 }
)) {
for (const branch of response.data) {
existingBranches.add(branch.name);
}
}

// Delete branches that are in merged PRs and still exist
const toDelete = [...mergedBranches].filter(
b => existingBranches.has(b) && !protectedBranches.has(b)
);

console.log(`Found ${toDelete.length} branches to delete.`);
let deleted = 0;
let failed = 0;

for (const branch of toDelete) {
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branch}`,
});
console.log(`Deleted: ${branch}`);
deleted++;
} catch (err) {
if (err.status === 422) {
console.log(`Already gone: ${branch}`);
} else {
console.error(`Failed to delete ${branch}: ${err.message}`);
failed++;
}
}
}

console.log(`Done. Deleted: ${deleted}, Failed: ${failed}`);
if (failed > 0) {
throw new Error(`Failed to delete ${failed} branch(es). See logs above.`);
}