Stale Branch Cleanup #146
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Stale Branch Cleanup | |
| on: | |
| schedule: | |
| - cron: "17 3 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| stale_days: | |
| description: Days without commit activity before a branch is considered stale | |
| required: false | |
| default: "30" | |
| delete_after_days: | |
| description: Days after warning before deletion is allowed | |
| required: false | |
| default: "7" | |
| dry_run: | |
| description: Report planned changes without creating warnings or deleting branches | |
| required: false | |
| default: "true" | |
| type: choice | |
| options: | |
| - "true" | |
| - "false" | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: read | |
| jobs: | |
| stale-branch-cleanup: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Scan stale branches | |
| uses: actions/github-script@v7 | |
| env: | |
| STALE_DAYS: ${{ github.event_name == 'workflow_dispatch' && inputs.stale_days || '30' }} | |
| DELETE_AFTER_DAYS: ${{ github.event_name == 'workflow_dispatch' && inputs.delete_after_days || '7' }} | |
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} | |
| with: | |
| script: | | |
| const marker = "<!-- stale-branch-cleanup -->"; | |
| const longLivedBranches = new Set([ | |
| "main", | |
| "master", | |
| "develop", | |
| "development", | |
| "staging", | |
| "production", | |
| "release", | |
| "gh-pages", | |
| ]); | |
| const staleDays = Number.parseInt(process.env.STALE_DAYS || "30", 10); | |
| const deleteAfterDays = Number.parseInt(process.env.DELETE_AFTER_DAYS || "7", 10); | |
| const dryRun = String(process.env.DRY_RUN || "false").toLowerCase() === "true"; | |
| if (!Number.isFinite(staleDays) || staleDays < 1) { | |
| throw new Error(`stale_days must be a positive integer, received ${process.env.STALE_DAYS}`); | |
| } | |
| if (!Number.isFinite(deleteAfterDays) || deleteAfterDays < 1) { | |
| throw new Error(`delete_after_days must be a positive integer, received ${process.env.DELETE_AFTER_DAYS}`); | |
| } | |
| const { owner, repo } = context.repo; | |
| const now = new Date(); | |
| const staleCutoffMs = now.getTime() - staleDays * 24 * 60 * 60 * 1000; | |
| const warningRefreshCutoffMs = now.getTime() - deleteAfterDays * 24 * 60 * 60 * 1000; | |
| const summary = { | |
| warned: [], | |
| skipped: [], | |
| deleted: [], | |
| dryRun: [], | |
| errors: [], | |
| }; | |
| const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)); | |
| const isoDate = (date) => date.toISOString().slice(0, 10); | |
| const addDays = (date, days) => new Date(date.getTime() + days * 24 * 60 * 60 * 1000); | |
| const branchUrl = (branch) => `https://github.com/${owner}/${repo}/tree/${encodeURIComponent(branch).replace(/%2F/g, "/")}`; | |
| const refForDelete = (branch) => `heads/${branch}`; | |
| const warningTitle = (branch) => `Stale branch warning: ${branch}`; | |
| const makeIssueBody = ({ branch, sha, commitDate, ageDays, warningDate }) => { | |
| const plannedDeletionDate = addDays(warningDate, deleteAfterDays); | |
| return `${marker} | |
| Branch \`${branch}\` has had no commit activity for ${ageDays} days and is older than the configured stale threshold of ${staleDays} days. | |
| - Branch: [\`${branch}\`](${branchUrl(branch)}) | |
| - Latest commit: \`${sha}\` | |
| - Latest commit date: ${commitDate.toISOString()} | |
| - Warning issued: ${warningDate.toISOString()} | |
| - Planned deletion date: ${isoDate(plannedDeletionDate)} | |
| If this branch is still needed, add a commit, open a pull request, protect the branch, or rename it to a long-lived branch name before the planned deletion date.`; | |
| }; | |
| const makePrCommentBody = ({ branch, sha, commitDate, ageDays }) => `${marker} | |
| Branch \`${branch}\` has had no commit activity for ${ageDays} days and is older than the configured stale threshold of ${staleDays} days. | |
| This branch has an open pull request, so it will not be deleted by this workflow. Please update the branch or close the pull request if it is no longer needed. | |
| - Latest commit: \`${sha}\` | |
| - Latest commit date: ${commitDate.toISOString()}`; | |
| const parseWarningDate = (issue) => { | |
| const body = issue.body || ""; | |
| const match = body.match(/Warning issued: ([0-9TZ:.\-+]+)/); | |
| const parsed = match ? new Date(match[1]) : new Date(issue.created_at); | |
| return Number.isNaN(parsed.getTime()) ? new Date(issue.created_at) : parsed; | |
| }; | |
| const branches = await github.paginate(github.rest.repos.listBranches, { | |
| owner, | |
| repo, | |
| per_page: 100, | |
| }); | |
| const repository = await github.rest.repos.get({ owner, repo }); | |
| const defaultBranch = repository.data.default_branch; | |
| const openPulls = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: "open", | |
| per_page: 100, | |
| }); | |
| const openPullByBranch = new Map(); | |
| for (const pull of openPulls) { | |
| if (pull.head?.repo?.owner?.login === owner && pull.head?.repo?.name === repo) { | |
| openPullByBranch.set(pull.head.ref, pull); | |
| } | |
| } | |
| const repositoryIssues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: "all", | |
| per_page: 100, | |
| }); | |
| const warningIssueByTitle = new Map(); | |
| for (const issue of repositoryIssues) { | |
| if (!issue.pull_request && issue.body?.includes(marker) && issue.title?.startsWith("Stale branch warning: ")) { | |
| warningIssueByTitle.set(issue.title, issue); | |
| } | |
| } | |
| for (const listedBranch of branches) { | |
| const branch = listedBranch.name; | |
| if (branch === defaultBranch) { | |
| summary.skipped.push(`${branch}: default branch`); | |
| continue; | |
| } | |
| if (listedBranch.protected) { | |
| summary.skipped.push(`${branch}: protected branch`); | |
| continue; | |
| } | |
| if (longLivedBranches.has(branch)) { | |
| summary.skipped.push(`${branch}: long-lived branch name`); | |
| continue; | |
| } | |
| const branchResponse = await github.rest.repos.getBranch({ owner, repo, branch }); | |
| const branchData = branchResponse.data; | |
| if (branchData.protected) { | |
| summary.skipped.push(`${branch}: protected branch`); | |
| continue; | |
| } | |
| const commit = branchData.commit; | |
| const commitDateValue = commit.commit?.committer?.date || commit.commit?.author?.date; | |
| if (!commitDateValue) { | |
| summary.skipped.push(`${branch}: latest commit date unavailable`); | |
| continue; | |
| } | |
| const commitDate = new Date(commitDateValue); | |
| if (Number.isNaN(commitDate.getTime())) { | |
| summary.skipped.push(`${branch}: latest commit date invalid`); | |
| continue; | |
| } | |
| if (commitDate.getTime() >= staleCutoffMs) { | |
| summary.skipped.push(`${branch}: active within ${staleDays} days`); | |
| continue; | |
| } | |
| const ageDays = daysBetween(commitDate, now); | |
| const openPull = openPullByBranch.get(branch); | |
| if (openPull) { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: openPull.number, | |
| per_page: 100, | |
| }); | |
| const recentWarning = comments.some((comment) => comment.body?.includes(marker) && new Date(comment.created_at).getTime() >= warningRefreshCutoffMs); | |
| if (recentWarning) { | |
| summary.skipped.push(`${branch}: open PR #${openPull.number} already has a recent warning`); | |
| continue; | |
| } | |
| if (dryRun) { | |
| summary.dryRun.push(`${branch}: would comment on open PR #${openPull.number}`); | |
| continue; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: openPull.number, | |
| body: makePrCommentBody({ branch, sha: commit.sha, commitDate, ageDays }), | |
| }); | |
| summary.warned.push(`${branch}: commented on open PR #${openPull.number}`); | |
| continue; | |
| } | |
| const title = warningTitle(branch); | |
| const existingIssue = warningIssueByTitle.get(title); | |
| if (existingIssue) { | |
| const warningDate = parseWarningDate(existingIssue); | |
| const plannedDeletionDate = addDays(warningDate, deleteAfterDays); | |
| const issueBody = makeIssueBody({ branch, sha: commit.sha, commitDate, ageDays, warningDate }); | |
| if (now.getTime() >= plannedDeletionDate.getTime()) { | |
| if (dryRun) { | |
| summary.dryRun.push(`${branch}: would delete after warning grace period ended on ${isoDate(plannedDeletionDate)}`); | |
| continue; | |
| } | |
| try { | |
| await github.rest.git.deleteRef({ | |
| owner, | |
| repo, | |
| ref: refForDelete(branch), | |
| }); | |
| summary.deleted.push(`${branch}: deleted ref ${refForDelete(branch)}`); | |
| if (existingIssue.state === "open") { | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: existingIssue.number, | |
| state: "closed", | |
| body: `${issueBody}\n\nDeleted branch ref \`${refForDelete(branch)}\` on ${now.toISOString()}.`, | |
| }); | |
| } | |
| } catch (error) { | |
| if (error.status === 404 || error.status === 422) { | |
| summary.deleted.push(`${branch}: ref was already absent`); | |
| if (existingIssue.state === "open") { | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: existingIssue.number, | |
| state: "closed", | |
| body: `${issueBody}\n\nBranch ref \`${refForDelete(branch)}\` was already absent on ${now.toISOString()}.`, | |
| }); | |
| } | |
| } else { | |
| summary.errors.push(`${branch}: deletion failed with ${error.message}`); | |
| } | |
| } | |
| continue; | |
| } | |
| if (dryRun) { | |
| summary.dryRun.push(`${branch}: warning issue #${existingIssue.number} active until ${isoDate(plannedDeletionDate)}`); | |
| continue; | |
| } | |
| if (existingIssue.state !== "open" || existingIssue.body !== issueBody) { | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: existingIssue.number, | |
| state: "open", | |
| body: issueBody, | |
| }); | |
| summary.warned.push(`${branch}: updated warning issue #${existingIssue.number}`); | |
| } else { | |
| summary.skipped.push(`${branch}: warning issue #${existingIssue.number} active until ${isoDate(plannedDeletionDate)}`); | |
| } | |
| continue; | |
| } | |
| const warningDate = now; | |
| const issueBody = makeIssueBody({ branch, sha: commit.sha, commitDate, ageDays, warningDate }); | |
| if (dryRun) { | |
| summary.dryRun.push(`${branch}: would create warning issue titled "${title}"`); | |
| continue; | |
| } | |
| const createdIssue = await github.rest.issues.create({ | |
| owner, | |
| repo, | |
| title, | |
| body: issueBody, | |
| }); | |
| warningIssueByTitle.set(title, createdIssue.data); | |
| summary.warned.push(`${branch}: created warning issue #${createdIssue.data.number}`); | |
| } | |
| const appendSection = async (title, entries) => { | |
| await core.summary.addHeading(title, 3); | |
| if (entries.length === 0) { | |
| await core.summary.addRaw("None\n"); | |
| } else { | |
| await core.summary.addList(entries); | |
| } | |
| }; | |
| await core.summary | |
| .addHeading("Stale Branch Cleanup") | |
| .addTable([ | |
| ["Setting", "Value"], | |
| ["stale_days", String(staleDays)], | |
| ["delete_after_days", String(deleteAfterDays)], | |
| ["dry_run", String(dryRun)], | |
| ["default_branch", defaultBranch], | |
| ["branches_scanned", String(branches.length)], | |
| ]); | |
| await appendSection("Warned", summary.warned); | |
| await appendSection("Deleted", summary.deleted); | |
| await appendSection("Skipped", summary.skipped); | |
| await appendSection("Dry-run actions", summary.dryRun); | |
| await appendSection("Errors", summary.errors); | |
| await core.summary.write(); | |
| if (summary.errors.length > 0) { | |
| core.setFailed(`Stale branch cleanup encountered ${summary.errors.length} error(s).`); | |
| } |