-
Notifications
You must be signed in to change notification settings - Fork 13.2k
GitHub Actions workflows for automated branch cleanup #29113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
ca09912
Add GitHub Actions workflows for automated branch cleanup
mvvmm b51306c
Update delete-closed-pr-branches workflow to skip merged PRs
mvvmm f90f66b
ty bonk
mvvmm 6534fd0
Optimize stale branch detection using GraphQL API to reduce API calls
mvvmm 7c81c9d
simplify stale cleanup to just delete any branch without an open PR t…
mvvmm a72fb1b
Add dry run on push for testing
mvvmm e88f67d
fix unkown JSON error field
mvvmm cba0d09
remove limit
mvvmm 0bbb4f3
remove push event and dry run logic
mvvmm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| # Fires immediately when a PR is closed without merging and deletes the head | ||
| # branch — provided it lives on this repo (not a fork) and is not protected. | ||
| # Merged PRs are excluded because the repo's "automatically delete head branches" | ||
| # setting already handles those. | ||
| # | ||
| # Fork branches are skipped at the job level via the `if:` condition because | ||
| # we don't own them and the API would return a 403. | ||
| # | ||
| # To test without deleting anything, trigger this workflow manually via | ||
| # workflow_dispatch and supply a branch name. Manual runs always dry-run | ||
| # — they log "Would delete: <branch>" without touching anything. | ||
|
|
||
| name: Delete closed PR branches | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [closed] | ||
| workflow_dispatch: | ||
| inputs: | ||
| branch: | ||
| description: "Branch name to dry-run against" | ||
| required: true | ||
| type: string | ||
|
|
||
| permissions: | ||
| contents: write | ||
|
|
||
| jobs: | ||
| delete-branch: | ||
| if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.merged == false) | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Delete branch associated with closed PR | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }} | ||
| BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.event.pull_request.head.ref }} | ||
| run: | | ||
| branch="$BRANCH" | ||
|
|
||
| if [ "$DRY_RUN" = "true" ]; then | ||
| echo "*** DRY RUN — no branches will be deleted ***" | ||
| fi | ||
|
|
||
| protected=$(gh api "repos/$GITHUB_REPOSITORY/branches?protected=true&per_page=100" \ | ||
| | jq -r '.[].name') | ||
|
|
||
| if echo "$protected" | grep -qx "$branch"; then | ||
| echo "Skipped (protected): $branch" | ||
| exit 0 | ||
| fi | ||
|
|
||
| if [ "$DRY_RUN" = "true" ]; then | ||
| echo "Would delete: $branch" | ||
| exit 0 | ||
| fi | ||
|
|
||
| gh api "repos/$GITHUB_REPOSITORY/git/refs/heads/$branch" \ | ||
| -X DELETE 2>/dev/null \ | ||
| && echo "Deleted: $branch" \ | ||
| || echo "Skipped (already gone): $branch" |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| # Runs daily to clean up two categories of branches: | ||
| # | ||
| # 1. Branches whose PR has been closed (merged or otherwise) — acts as a | ||
| # retroactive safety net for anything the event-driven workflow missed. | ||
| # | ||
| # 2. Branches with no associated PR at all that haven't had a commit in | ||
| # over 30 days — these are abandoned branches that were never turned | ||
| # into a PR. | ||
| # | ||
| # Both parts respect protected branches and skip fork branches. | ||
| # Up to 100 branches are deleted per run, oldest first. | ||
| # | ||
| # To test without deleting anything, trigger this workflow manually via | ||
| # workflow_dispatch and leave dry-run set to "true" (the default). The | ||
| # run will log "Would delete: ..." for every branch it would have removed. | ||
|
|
||
| name: Delete stale branches | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: "0 4 * * *" | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry-run: | ||
| description: "Dry run (log what would be deleted without deleting)" | ||
| required: false | ||
| default: "true" | ||
| type: choice | ||
| options: | ||
| - "true" | ||
| - "false" | ||
|
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: read | ||
|
|
||
| jobs: | ||
| delete-stale-branches: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Delete closed PR branches and unassociated branches older than 30 days | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run || 'false' }} | ||
| run: | | ||
| LIMIT=100 | ||
| deleted=0 | ||
|
|
||
| if [ "$DRY_RUN" = "true" ]; then | ||
| echo "*** DRY RUN — no branches will be deleted ***" | ||
| fi | ||
|
|
||
| protected=$(gh api "repos/$GITHUB_REPOSITORY/branches?protected=true&per_page=100" \ | ||
| | jq -r '.[].name') | ||
|
|
||
| is_protected() { | ||
| echo "$protected" | grep -qx "$1" | ||
| } | ||
|
|
||
| delete_branch() { | ||
| local branch="$1" | ||
| local reason="$2" | ||
|
|
||
| if is_protected "$branch"; then | ||
| echo "Skipped (protected): $branch" | ||
| return | ||
| fi | ||
|
|
||
| if [ "$DRY_RUN" = "true" ]; then | ||
| echo "Would delete ($reason): $branch" | ||
| deleted=$((deleted + 1)) | ||
| return | ||
| fi | ||
|
|
||
| gh api "repos/$GITHUB_REPOSITORY/git/refs/heads/$branch" \ | ||
| -X DELETE 2>/dev/null \ | ||
| && { echo "Deleted ($reason): $branch"; deleted=$((deleted + 1)); } \ | ||
| || echo "Skipped (already gone): $branch" | ||
| } | ||
|
|
||
| # ---------------------------------------------------------------- | ||
| # Part 1: Branches from closed non-fork PRs, oldest first | ||
| # ---------------------------------------------------------------- | ||
| echo "--- Closed PR branches (oldest first, limit $LIMIT) ---" | ||
|
|
||
| # Write sorted branch names to a temp file to avoid subshell scoping | ||
| # issues with the deleted counter (piped while loops run in a subshell). | ||
| closed_pr_branches=$(mktemp) | ||
|
|
||
| gh pr list \ | ||
| --repo "$GITHUB_REPOSITORY" \ | ||
| --state closed \ | ||
| --limit 500 \ | ||
| --json headRefName,headRepositoryOwner,baseRepositoryOwner,closedAt \ | ||
| | jq -r ' | ||
| .[] | ||
| | select(.headRepositoryOwner.login == .baseRepositoryOwner.login) | ||
| | [.closedAt, .headRefName] | ||
| | @tsv | ||
| ' \ | ||
| | sort \ | ||
| | awk -F'\t' '{print $2}' \ | ||
| | sort -u > "$closed_pr_branches" | ||
|
|
||
| while IFS= read -r branch; do | ||
| [ "$deleted" -ge "$LIMIT" ] && break | ||
| delete_branch "$branch" "closed PR" | ||
| done < "$closed_pr_branches" | ||
|
|
||
| rm -f "$closed_pr_branches" | ||
|
|
||
| # ---------------------------------------------------------------- | ||
| # Part 2: Branches with no PR older than 30 days, oldest first | ||
| # ---------------------------------------------------------------- | ||
| echo "--- Unassociated branches older than 30 days (oldest first, limit $LIMIT) ---" | ||
|
|
||
| pr_branches=$(gh pr list \ | ||
| --repo "$GITHUB_REPOSITORY" \ | ||
| --state all \ | ||
| --limit 1000 \ | ||
| --json headRefName,headRepositoryOwner,baseRepositoryOwner \ | ||
| | jq -r '.[] | select(.headRepositoryOwner.login == .baseRepositoryOwner.login) | .headRefName' \ | ||
| | sort -u) | ||
|
|
||
| cutoff=$(date -d '30 days ago' --utc +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ | ||
| || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ) | ||
|
|
||
| echo "Cutoff date: $cutoff" | ||
|
|
||
| # Use a temp file to collect and sort candidates, avoiding both the | ||
| # subshell scoping issue and fragile printf string concatenation. | ||
| candidates=$(mktemp) | ||
|
|
||
| page=1 | ||
| while true; do | ||
| batch=$(gh api "repos/$GITHUB_REPOSITORY/branches?per_page=100&page=$page" \ | ||
| | jq -r '.[].name') | ||
| [ -z "$batch" ] && break | ||
|
|
||
| while IFS= read -r branch; do | ||
| if echo "$pr_branches" | grep -qx "$branch"; then | ||
| continue | ||
| fi | ||
|
|
||
| last_commit=$(gh api "repos/$GITHUB_REPOSITORY/commits?sha=$branch&per_page=1" \ | ||
| | jq -r '.[0].commit.committer.date') | ||
|
|
||
| if [[ "$last_commit" < "$cutoff" ]]; then | ||
| # Use printf with explicit format to safely handle special characters | ||
| printf '%s\t%s\n' "$last_commit" "$branch" >> "$candidates" | ||
| fi | ||
| done <<< "$batch" | ||
|
|
||
| page=$((page + 1)) | ||
| done | ||
|
|
||
| # Sort oldest-first by the ISO 8601 date prefix, then delete up to limit. | ||
| # Use tab as delimiter for exact field splitting — avoids partial name matches. | ||
| while IFS=$'\t' read -r last_commit branch; do | ||
| [ "$deleted" -ge "$LIMIT" ] && break | ||
| delete_branch "$branch" "no PR, last commit $last_commit" | ||
| done < <(sort "$candidates") | ||
|
|
||
| rm -f "$candidates" | ||
|
|
||
| echo "--- Done: $deleted branch(es) ${DRY_RUN:+would be }deleted ---" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.