From b85168fdefbbb554906bfbcd318e3b64595f74de Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Fri, 9 Oct 2026 02:34:01 +0100 Subject: [PATCH 1/2] ci: delete a PR's head branch after it merges, with an allowlist GitHub has no account-wide setting for this, the per-repo toggle deletes every merged head branch, and Free-plan private repos cannot protect long-lived branches such as develop. This self-contained workflow runs on merged same-repo PRs and deletes the head branch unless it is the default branch, one of main/master/develop/staging/test/gh-pages/ deploy-*/release/*, or the base of another open PR. A branch that is already gone counts as success. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi --- .github/workflows/branch-cleanup.yml | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 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 0000000..c496435 --- /dev/null +++ b/.github/workflows/branch-cleanup.yml @@ -0,0 +1,68 @@ +# Deletes a pull request's head branch once the PR has been merged. +# +# Why a workflow and not a setting: +# - GitHub has no account-wide "delete head branches" setting. It is a +# per-repository toggle that is easy to miss on new repos, and it deletes +# every merged head branch without exception. +# - Private repositories on the Free plan cannot protect branches, so nothing +# stops that toggle from deleting a long-lived branch such as `develop` when +# it is merged as the head of a PR. +# - So this keeps an explicit allowlist instead: the default branch, main, +# master, develop, staging, test, gh-pages, deploy-* and release/* are never +# deleted, and neither is a branch that an open PR still uses as its base. +# +# Self-contained on purpose (no reusable workflow), and it only acts on +# same-repository heads, never on a fork's branch. +name: branch-cleanup + +on: + pull_request: + types: [closed] + +permissions: {} + +jobs: + delete-merged-branch: + if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: write + pull-requests: read + steps: + - name: Delete the merged head branch + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if [ "$HEAD_REF" = "$DEFAULT_BRANCH" ]; then + echo "::notice::Keeping $HEAD_REF: it is the default branch." + exit 0 + fi + + case "$HEAD_REF" in + main | master | develop | staging | test | gh-pages | deploy-* | release/*) + echo "::notice::Keeping $HEAD_REF: it is on the long-lived branch allowlist." + exit 0 + ;; + esac + + children="$(gh pr list --repo "$REPO" --base "$HEAD_REF" --state open --json number --jq length)" + if [ "$children" -gt 0 ]; then + echo "::notice::Keeping $HEAD_REF: $children open PR(s) use it as their base." + exit 0 + fi + + ref="$(jq -rn --arg r "$HEAD_REF" '$r|@uri')" + if out="$(gh api -X DELETE "repos/$REPO/git/refs/heads/$ref" 2>&1)"; then + echo "Deleted branch $HEAD_REF." + elif printf '%s' "$out" | grep -Eq 'Reference does not exist|HTTP 404'; then + echo "::notice::Branch $HEAD_REF was already deleted." + else + printf '%s\n' "$out" >&2 + exit 1 + fi From 3fd091bbfb3f9b153df5c748d06c56013b579211 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Fri, 9 Oct 2026 02:48:33 +0100 Subject: [PATCH 2/2] ci(branch-cleanup): only delete the merged commit; keep heads of open PRs From CodeRabbit review: keep the branch when another open PR uses it as its head; read the branch's current SHA first and keep it if it moved past the merged PR head (REST has no conditional delete, so only the instant between read and DELETE is unguarded); comment each permission. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012rYJxu978iUSGFZ1UuqTsi --- .github/workflows/branch-cleanup.yml | 37 ++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index c496435..acc371c 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -9,7 +9,8 @@ # it is merged as the head of a PR. # - So this keeps an explicit allowlist instead: the default branch, main, # master, develop, staging, test, gh-pages, deploy-* and release/* are never -# deleted, and neither is a branch that an open PR still uses as its base. +# deleted, and neither is a branch that an open PR still uses as its base or +# head, or one that has moved past the merged commit. # # Self-contained on purpose (no reusable workflow), and it only acts on # same-repository heads, never on a fork's branch. @@ -27,12 +28,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 permissions: - contents: write - pull-requests: read + contents: write # delete the merged head branch's ref + pull-requests: read # list open PRs that still use the branch steps: - name: Delete the merged head branch env: HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} @@ -51,15 +53,36 @@ jobs: ;; esac - children="$(gh pr list --repo "$REPO" --base "$HEAD_REF" --state open --json number --jq length)" - if [ "$children" -gt 0 ]; then - echo "::notice::Keeping $HEAD_REF: $children open PR(s) use it as their base." + bases="$(gh pr list --repo "$REPO" --base "$HEAD_REF" --state open --json number --jq length)" + if [ "$bases" -gt 0 ]; then + echo "::notice::Keeping $HEAD_REF: $bases open PR(s) use it as their base." + exit 0 + fi + heads="$(gh pr list --repo "$REPO" --head "$HEAD_REF" --state open --json number --jq length)" + if [ "$heads" -gt 0 ]; then + echo "::notice::Keeping $HEAD_REF: $heads other open PR(s) use it as their head." exit 0 fi ref="$(jq -rn --arg r "$HEAD_REF" '$r|@uri')" + if ! current="$(gh api "repos/$REPO/git/ref/heads/$ref" --jq .object.sha 2>&1)"; then + if printf '%s' "$current" | grep -Eq 'Not Found|HTTP 404'; then + echo "::notice::Branch $HEAD_REF was already deleted." + exit 0 + fi + printf '%s\n' "$current" >&2 + exit 1 + fi + # Only delete the commit that was merged. REST has no conditional + # delete, so a push in the moment between this check and the DELETE + # is not caught; anything pushed after the merge before now is. + if [ "$current" != "$HEAD_SHA" ]; then + echo "::notice::Keeping $HEAD_REF: it now points at $current, not the merged head $HEAD_SHA." + exit 0 + fi + if out="$(gh api -X DELETE "repos/$REPO/git/refs/heads/$ref" 2>&1)"; then - echo "Deleted branch $HEAD_REF." + echo "Deleted branch $HEAD_REF ($HEAD_SHA)." elif printf '%s' "$out" | grep -Eq 'Reference does not exist|HTTP 404'; then echo "::notice::Branch $HEAD_REF was already deleted." else