Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/delete-closed-pr-branches.yml
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"
166 changes: 166 additions & 0 deletions .github/workflows/delete-stale-branches.yml
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 ---"
Loading