From 2e4c00bd56080bc5ab0dddc7e6cd858866b7152b Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Fri, 9 Oct 2026 02:48:04 +0100 Subject: [PATCH] ci(branch-cleanup): only delete the merged commit; keep heads of open PRs From CodeRabbit review of the same workflow on sibling repos: - Keep the branch when another open PR uses it as its head, not only as its base. - Read the branch's current SHA first and keep it if it no longer points at the merged PR head (someone pushed after the merge). REST has no conditional delete, so only the instant between that read and the DELETE is unguarded; the comment says so. - Comment each job permission (zizmor undocumented-permissions). 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