Saltar al contenido
mypipelines
Shared (cross-cutting)·Reusable workflow·on: workflow_call

Shared Delete Branch

Shared - Delete Merged Branch

.github/workflows/shared-delete-branch.yml

.github/workflows/shared-delete-branch.yml
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
fi