name: Delete merged branches on: schedule: - cron: "0 2 * * *" # Run daily at 02:00 UTC workflow_dispatch: inputs: dry_run: description: "Only log branches that would be deleted (no deletion)" type: boolean default: true permissions: contents: write # required to delete branch refs pull-requests: read jobs: delete-merged-branches: runs-on: ubuntu-latest steps: - name: Delete branches of merged PRs uses: actions/github-script@v7 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; // dry_run is only set on workflow_dispatch; scheduled runs delete for real. const dryRun = context.eventName === "workflow_dispatch" ? context.payload.inputs?.dry_run === "true" || context.payload.inputs?.dry_run === true : false; // Only look at PRs updated within this window on scheduled runs, so we don't // re-scan the entire history every day. Raise this for an initial cleanup. const lookbackDays = 30; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - lookbackDays); // Long-lived branches that must never be deleted (besides the default branch). const protectedNames = new Set(); // Resolve the default branch once. const { data: repoData } = await github.rest.repos.get({ owner, repo }); const defaultBranch = repoData.default_branch; protectedNames.add(defaultBranch); console.log(`Mode: ${dryRun ? "DRY RUN (no deletion)" : "LIVE"}`); console.log(`Default branch: ${defaultBranch}`); const candidates = new Set(); let page = 1; let stop = false; while (!stop) { const { data: prs } = await github.rest.pulls.list({ owner, repo, state: "closed", sort: "updated", direction: "desc", per_page: 100, page, }); if (prs.length === 0) break; for (const pr of prs) { // Sorted by updated desc: once we pass the cutoff we can stop paginating. if (new Date(pr.updated_at) < cutoff) { stop = true; break; } if (!pr.merged_at) continue; // only merged PRs if (!pr.head.repo) continue; // head fork was deleted if (pr.head.repo.full_name !== pr.base.repo.full_name) continue; // skip forks const branch = pr.head.ref; if (protectedNames.has(branch)) continue; // default / kept branches candidates.add(branch); } page++; if (page > 50) break; // safety cap } console.log(`Found ${candidates.size} unique candidate branch(es) from merged PRs.`); let deleted = 0; let skipped = 0; for (const branch of candidates) { // Confirm the branch still exists and isn't protected. let branchData; try { const res = await github.rest.repos.getBranch({ owner, repo, branch }); branchData = res.data; } catch (error) { if (error.status === 404) { // Already deleted (e.g. auto-delete head branch) — nothing to do. continue; } console.log(`Failed to inspect "${branch}": ${error.message}`); skipped++; continue; } if (branchData.protected) { console.log(`Skipped "${branch}" (protected branch)`); skipped++; continue; } if (dryRun) { console.log(`[dry-run] Would delete "${branch}"`); continue; } try { await github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }); console.log(`Deleted "${branch}"`); deleted++; } catch (error) { console.log(`Failed to delete "${branch}": ${error.message}`); skipped++; } } console.log( dryRun ? `Dry run complete. ${candidates.size} candidate(s) would be processed.` : `Done. Deleted ${deleted} branch(es), skipped ${skipped}.` );