diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml new file mode 100644 index 0000000..acc371c --- /dev/null +++ b/.github/workflows/branch-cleanup.yml @@ -0,0 +1,91 @@ +# 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 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. +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 # 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 }} + 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 + + 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 ($HEAD_SHA)." + 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