name: Shared - Delete Merged Branch
on: workflow_call: inputs: branch_name: description: 'Branch name to delete (if empty, detects from merge commit)' required: false type: string default: '' dry_run: description: 'Only log what would be deleted, do not actually delete' required: false type: boolean default: false fail_on_protected: description: 'Hard-fail when target branch is protected (develop/main). Default true for explicit branch_name inputs.' required: false type: boolean default: true
outputs: deleted_branch: description: 'Name of deleted branch (empty if not deleted)' value: ${{ jobs.delete.outputs.branch }} status: description: 'Status: deleted, skipped, protected, not_found, unmerged' value: ${{ jobs.delete.outputs.status }}
# Least privilege for what this workflow actually does. Without it the# job inherits whatever the calling repository's default happens to be,# which on older repositories is write-all.permissions: contents: write
jobs: delete: name: Delete Branch runs-on: ubuntu-latest # Two API calls; a hung one should not hold the runner for GitHub's # six-hour default in every consumer at once. timeout-minutes: 5 # A cleanup hiccup must never fail the calling pipeline (valid here: this is a normal job, # unlike the reusable-workflow caller). continue-on-error: true
outputs: branch: ${{ steps.delete.outputs.branch }} status: ${{ steps.delete.outputs.status }}
steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0
- name: Detect and delete branch id: delete env: GH_TOKEN: ${{ github.token }} INPUT_BRANCH: ${{ inputs.branch_name }} DRY_RUN: ${{ inputs.dry_run }} FAIL_ON_PROTECTED: ${{ inputs.fail_on_protected }} run: | # Protected branches - never delete. Only develop and main are protected. PROTECTED_BRANCHES="main develop"
# Determine branch to delete EXPLICIT_INPUT=false if [ -n "$INPUT_BRANCH" ]; then BRANCH="$INPUT_BRANCH" EXPLICIT_INPUT=true else COMMIT_SHA=$(git rev-parse HEAD) COMMIT_MSG=$(git log -1 --pretty=%B)
# Method 1: Merge commit message if echo "$COMMIT_MSG" | grep -q "Merge pull request"; then BRANCH=$(echo "$COMMIT_MSG" | grep -oP "from [^/]+/\K[^\s]+" | head -1) elif echo "$COMMIT_MSG" | grep -q "Merge branch"; then BRANCH=$(echo "$COMMIT_MSG" | grep -oP "Merge branch '\K[^']+" | head -1) fi
# Method 2: GitHub API (handles squash merges) if [ -z "$BRANCH" ]; then echo "Trying GitHub API to find PR for commit $COMMIT_SHA" BRANCH=$(gh api "repos/${{ github.repository }}/commits/${COMMIT_SHA}/pulls" \ --jq '.[0].head.ref // empty' 2>/dev/null || echo "") fi
if [ -z "$BRANCH" ]; then echo "Could not detect source branch from commit" echo "status=not_found" >> $GITHUB_OUTPUT echo "branch=" >> $GITHUB_OUTPUT exit 0 fi fi
echo "Detected branch: $BRANCH"
# Check if branch is protected. NOTE: release/* is intentionally NOT protected — # a release/vX.Y.Z branch is transient and must be deleted once merged to main. IS_PROTECTED=false for protected in $PROTECTED_BRANCHES; do if [ "$BRANCH" = "$protected" ]; then IS_PROTECTED=true break fi done
if [ "$IS_PROTECTED" = "true" ]; then echo "::warning::Branch '$BRANCH' is protected (develop/main) — refusing to delete." echo "status=protected" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT if [ "$EXPLICIT_INPUT" = "true" ] && [ "$FAIL_ON_PROTECTED" = "true" ]; then echo "::error::Refusing to delete protected branch '$BRANCH' (explicit input + fail_on_protected=true)." exit 1 fi exit 0 fi
# Validate the branch was actually MERGED before deleting (auto-detected branches only). # Uses the associated PR's merged_at, so it works for merge-commit AND squash merges. # Never deletes unmerged work; if it can't verify a merged PR, it skips deletion. if [ "$EXPLICIT_INPUT" = "false" ]; then MERGED_AT=$(gh api "repos/${{ github.repository }}/commits/${COMMIT_SHA}/pulls" \ --jq "[.[] | select(.head.ref==\"${BRANCH}\" and .merged_at != null)] | .[0].merged_at // empty" 2>/dev/null || echo "") if [ -z "$MERGED_AT" ]; then echo "::warning::Branch '$BRANCH' is not a verified-merged PR head for ${COMMIT_SHA} — skipping delete." echo "status=unmerged" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT exit 0 fi echo "Branch '$BRANCH' verified merged at ${MERGED_AT}." fi
# Check if branch exists if ! git ls-remote --heads origin "$BRANCH" | grep -q .; then echo "Branch '$BRANCH' does not exist on remote" echo "status=not_found" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT exit 0 fi
# Delete branch if [ "$DRY_RUN" = "true" ]; then echo "[DRY RUN] Would delete branch: $BRANCH" echo "status=skipped" >> $GITHUB_OUTPUT else echo "Deleting branch: $BRANCH" gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$BRANCH" || true echo "status=deleted" >> $GITHUB_OUTPUT fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
# Summary echo "### Branch Cleanup" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "$DRY_RUN" = "true" ]; then echo "**[DRY RUN]** Would delete: \`$BRANCH\`" >> $GITHUB_STEP_SUMMARY else echo "Deleted branch: \`$BRANCH\`" >> $GITHUB_STEP_SUMMARY fiShared (cross-cutting)·Reusable workflow·on: workflow_call
Shared Delete Branch
Shared - Delete Merged Branch
.github/workflows/shared-delete-branch.yml