diff --git a/.github/workflows/cleanup-branches.yml b/.github/workflows/cleanup-branches.yml new file mode 100644 index 000000000..5d05aca1d --- /dev/null +++ b/.github/workflows/cleanup-branches.yml @@ -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.`); + }