From 177cb544cb3b04d3d295ed9c9c4e550416993438 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Apr 2026 15:10:09 -0400 Subject: [PATCH] =?UTF-8?q?chore(ci):=20add=20branch-cleanup=20workflow=20?= =?UTF-8?q?=E2=80=94=20auto-delete=20on=20merge=20+=20weekly=20sweep=20(#2?= =?UTF-8?q?051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/branch-cleanup.yml with two jobs: - delete-merged-branch: fires on pull_request closed+merged, immediately deletes the head branch. Belt-and-suspenders alongside the repo's delete_branch_on_merge setting (see issue for the one-line owner action). - sweep-orphaned-branches: runs weekly (Sunday 4am UTC) and on workflow_dispatch. Paginates all branches, deletes any whose only closed PRs are merged — cleans up branches that pre-date the setting change. Both jobs use the pinned actions/github-script hash already used across the repo. Protected branches (main, develop, release) are never touched. 422 responses (branch already gone) are treated as success. Closes #2050 Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/branch-cleanup.yml | 123 +++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/branch-cleanup.yml diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml new file mode 100644 index 000000000..530ba3742 --- /dev/null +++ b/.github/workflows/branch-cleanup.yml @@ -0,0 +1,123 @@ +name: Branch Cleanup + +on: + pull_request: + types: [closed] + schedule: + - cron: '0 4 * * 0' # Sunday 4am UTC — weekly orphan sweep + workflow_dispatch: + +permissions: + contents: write + pull-requests: read + +jobs: + # Runs immediately when a PR is merged — deletes the head branch. + # Belt-and-suspenders alongside the repo's delete_branch_on_merge setting, + # which handles web/API merges but may be bypassed by some CLI paths. + delete-merged-branch: + name: Delete merged PR branch + runs-on: ubuntu-latest + timeout-minutes: 2 + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true + steps: + - name: Delete head branch + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const branch = context.payload.pull_request.head.ref; + const protectedBranches = ['main', 'develop', 'release']; + if (protectedBranches.includes(branch)) { + core.info(`Skipping protected branch: ${branch}`); + return; + } + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}`, + }); + core.info(`Deleted branch: ${branch}`); + } catch (e) { + // 422 = branch already deleted (e.g. by delete_branch_on_merge setting) + if (e.status === 422) { + core.info(`Branch already deleted: ${branch}`); + } else { + throw e; + } + } + + # Runs weekly to catch any orphaned branches whose PRs were merged + # before this workflow existed, or that slipped through edge cases. + sweep-orphaned-branches: + name: Weekly orphaned branch sweep + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - name: Delete branches from merged PRs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const protectedBranches = new Set(['main', 'develop', 'release']); + const deleted = []; + const skipped = []; + + // Paginate through all branches (100 per page) + let page = 1; + let allBranches = []; + while (true) { + const { data } = await github.rest.repos.listBranches({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + page, + }); + allBranches = allBranches.concat(data); + if (data.length < 100) break; + page++; + } + + core.info(`Scanning ${allBranches.length} branches...`); + + for (const branch of allBranches) { + if (protectedBranches.has(branch.name)) continue; + + // Find the most recent closed PR for this branch + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branch.name}`, + state: 'closed', + per_page: 1, + sort: 'updated', + direction: 'desc', + }); + + if (prs.length === 0 || !prs[0].merged_at) { + skipped.push(branch.name); + continue; + } + + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch.name}`, + }); + deleted.push(branch.name); + } catch (e) { + if (e.status !== 422) { + core.warning(`Failed to delete ${branch.name}: ${e.message}`); + } + } + } + + const summary = [ + `Deleted ${deleted.length} orphaned branch(es).`, + deleted.length > 0 ? ` Removed: ${deleted.join(', ')}` : '', + skipped.length > 0 ? ` Skipped (no merged PR): ${skipped.length} branch(es)` : '', + ].filter(Boolean).join('\n'); + + core.info(summary); + await core.summary.addRaw(summary).write();