bundles/github/skills/release-cleanup/SKILL.md
Verify a release was fully promoted through develop, staging, and master/main, then prune merged local and remote branches and stale git worktrees. Squash-merge aware — uses GitHub PR merge state as the merge oracle, not commit ancestry. Use when the user asks to clean up branches after a deploy, prune worktrees, remove merged branches, tidy up after promoting develop to staging to master, or confirm nothing stale was left behind before pruning.
npx skillsauth add shipshitdev/library release-cleanupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Confirm a release was fully promoted up the branch chain, then prune the branches and git worktrees that promotion left behind. Verification is a hard gate: never prune until each branch's work is proven to have reached the production branch and no in-flight work is stranded.
This skill is standalone and manually triggerable. It does not promote code (use
release-pr-gates for that) and does not deploy (use deploy). It runs after a
promotion has landed and tidies up.
Commit ancestry is NOT a reliable merge signal. GitHub's default merge mode is
squash, which collapses a branch into a single new commit on the base. After a
squash merge the branch tip is not an ancestor of the base, so git branch --merged / --no-merged and A..B ranges all report a fully-merged branch as
unmerged. Rebase-merges have the same property.
Consequence if you trust ancestry on a squash repo:
git branch -d refuses every local merged branch.Therefore this skill's merge oracle is GitHub PR state first, ancestry second:
A branch's work is IN the production branch iff EITHER
MERGED and that PR's mergeCommit is an ancestor of the
production branch (covers squash, rebase, and merge-commit), OROnly branches that satisfy this are prunable. Everything else is reported, never deleted.
Inputs:
develop -> staging -> master/main)verify (gate only), dry-run (default, plan only), or prune (execute after confirmation)Outputs:
Creates/Modifies:
git worktree prunegit remote prune)External Side Effects:
git push origin --deleteConfirmation Required:
Delegates To:
release-pr-gates when the chain is NOT fully promoted and the user wants to finish the promotion firstgh-fix-ci when a promotion PR is still open with failing checksgit-safety when a branch about to be pruned may contain secrets in history worth scrubbing firstdevelop -> staging -> master and you want to delete the merged feature/release branches and worktreesDo not use this skill to promote code or to delete unmerged work. It only removes what is provably in the production branch.
Protected branches are never deleted:
develop staging master main + the currently checked-out branch + HEAD
Hard rules:
git branch --merged alone. A branch is prunable only when its
work is proven to be in the production branch.dry-run: print the exact plan and stop. Deletion only
happens in prune mode after the user confirms the printed plan.git branch -D (force local delete) is used ONLY for a local branch the oracle
has proven is in the production branch — squash/rebase merges legitimately
require it because -d cannot see them. For any branch NOT proven-in-prod,
force flags are never used; report it instead.git worktree remove --force and deleting a remote branch the oracle has NOT
proven-in-prod are never done automatically.gh auth status -h github.com
git status -sb
git remote -v
git fetch --all --prune
gh repo view --json nameWithOwner,defaultBranchRef
git branch -r --list 'origin/develop' 'origin/staging' 'origin/master' 'origin/main'
Determine the chain from the branches that actually exist on the remote:
master if it exists, else main, else the repo default branch.develop -> staging -> production that exists.develop nor staging exists (e.g. a master-only repo), the chain
collapses to "everything is merged into the production branch" and verification
checks only that.Snapshot every PR once — this is the data the Merge Oracle runs against:
gh pr list --state all --limit 1000 \
--json number,headRefName,baseRefName,state,mergedAt,mergeCommit \
> /tmp/rc_prs.json
Raise --limit if the repo has more open+closed PRs than that.
Prove each trunk hop carries no un-promoted commits. Ancestry is the first signal, but trunk promotions are occasionally squash-merged too, so corroborate any non-empty result against the latest merged promotion PR for that hop before declaring the release incomplete.
# develop -> staging
git log --oneline origin/staging..origin/develop
gh pr list --base staging --head develop --state merged --limit 1 \
--json number,mergedAt,mergeCommit
# staging -> production
git log --oneline origin/master..origin/staging
gh pr list --base master --head staging --state merged --limit 1 \
--json number,mergedAt,mergeCommit
When staging does not exist, check develop against production directly
(origin/master..origin/develop + the --base master --head develop PR).
Interpreting a non-empty hop:
Run the Merge Oracle over every non-protected remote branch. Do NOT use
git branch -r --no-merged for this — it lies on squash repos.
PROD=origin/master # or origin/main per Phase 1
classify_branch() { # arg: branch name without origin/
local b="$1" rec st mc base num
rec=$(jq -c --arg b "$b" \
'[.[]|select(.headRefName==$b)]|sort_by(.number)|last' /tmp/rc_prs.json)
if [ -z "$rec" ] || [ "$rec" = "null" ]; then
git merge-base --is-ancestor "refs/remotes/origin/$b" "$PROD" 2>/dev/null \
&& { echo "PRUNABLE_NO_PR_FF"; return; }
git merge-base --is-ancestor "refs/remotes/origin/$b" origin/develop 2>/dev/null \
&& echo "IN_DEVELOP_NO_PR" || echo "STRANDED_NO_PR"
return
fi
st=$(jq -r '.state' <<<"$rec")
mc=$(jq -r '.mergeCommit.oid // empty' <<<"$rec")
base=$(jq -r '.baseRefName' <<<"$rec")
num=$(jq -r '.number' <<<"$rec")
case "$st" in
OPEN) echo "IN_FLIGHT_OPEN_PR(#$num->$base)";;
CLOSED) git merge-base --is-ancestor "refs/remotes/origin/$b" "$PROD" 2>/dev/null \
&& echo "PRUNABLE_CLOSED_PR_IN_PROD(#$num)" \
|| echo "STRANDED_CLOSED_UNMERGED(#$num)";;
MERGED)
if [ -n "$mc" ] && git merge-base --is-ancestor "$mc" "$PROD" 2>/dev/null; then
echo "PRUNABLE_IN_PROD(#$num)"
elif git merge-base --is-ancestor "refs/remotes/origin/$b" "$PROD" 2>/dev/null; then
echo "PRUNABLE_IN_PROD(#$num)"
else
echo "MERGED_NOT_YET_IN_PROD(#$num->$base)"
fi;;
esac
}
# Drive it over all non-protected remote branches:
git branch -r --format '%(refname:short)' \
| grep -v -- '->' \
| sed 's#^origin/##' \
| grep -vxE 'origin|develop|staging|master|main|HEAD' \
| while read -r b; do printf '%-50s %s\n' "$b" "$(classify_branch "$b")"; done
Buckets and what they mean:
PRUNABLE_* — work is in the production branch. Safe to prune.MERGED_NOT_YET_IN_PROD — PR merged into develop/staging but not yet promoted to
production. NOT prunable yet; this is a real "release not fully promoted" signal
for that branch. Report it.IN_FLIGHT_OPEN_PR — open PR. In progress. Skip, never prune.IN_DEVELOP_NO_PR — landed on develop with no PR (direct push). Treat like
develop content; prune only if also in prod by ancestry.STRANDED_* — no merged PR and not in develop. Genuinely forgotten work.
Report loudly, never prune.Gate outcome:
release-pr-gates.STRANDED_* branch => report as a warning; the user decides whether it was
meant to ship. This is the "nothing is stale" guarantee.The prunable remote set is exactly the branches the oracle tagged PRUNABLE_* in
Phase 2b. Now compute the local and worktree sets the same way.
CURRENT="$(git symbolic-ref --quiet --short HEAD || echo)"
PROTECT="develop|staging|master|main|${CURRENT:-__none__}"
# Local branches — classify each with the SAME oracle (reuse classify_branch,
# but test the LOCAL ref, not origin/, for the ancestry fallback).
git branch --format '%(refname:short)' \
| grep -vxE "$PROTECT" \
| while read -r b; do
rec=$(jq -c --arg b "$b" \
'[.[]|select(.headRefName==$b)]|sort_by(.number)|last' /tmp/rc_prs.json)
mc=$(jq -r '.mergeCommit.oid // empty' <<<"${rec:-null}")
st=$(jq -r '.state // empty' <<<"${rec:-null}")
if { [ "$st" = "MERGED" ] && [ -n "$mc" ] \
&& git merge-base --is-ancestor "$mc" "$PROD" 2>/dev/null; } \
|| git merge-base --is-ancestor "$b" "$PROD" 2>/dev/null; then
echo "PRUNABLE_LOCAL $b (needs -D if squash-merged)"
else
echo "KEEP_LOCAL $b ($st)"
fi
done
# Worktrees
git worktree list --porcelain
For each worktree other than the main checkout, classify it:
git -C <path> status --porcelain empty => safe to remove.Print the plan as three explicit lists — local branches, remote branches,
worktree paths — each annotated with the oracle verdict and PR number, plus a
skipped list with reasons (MERGED_NOT_YET_IN_PROD, IN_FLIGHT_OPEN_PR,
STRANDED_*, dirty worktree). Then stop and ask for confirmation. In dry-run
(default) and verify modes, end here.
prune Mode, After Confirmation)Only after the user confirms the printed plan:
# Local branches proven-in-prod. Try -d first; fall back to -D ONLY when the
# oracle proved the branch is in prod (squash/rebase merges require it).
for b in <prunable-local-branches>; do
git branch -d "$b" 2>/dev/null || git branch -D "$b"
done
# Remote branches proven-in-prod
git push origin --delete <branch> ...
# Worktrees flagged safe
git worktree remove <path> ... # never --force; refuses on dirty
git worktree prune
# Drop stale remote-tracking refs
git remote prune origin
git fetch --all --prune
Rules during execution:
-D is permitted ONLY for branches the Phase-3 oracle tagged PRUNABLE_LOCAL.
Never blind-force a branch that is not proven-in-prod.git worktree remove refuses (dirty/locked), do not --force. Report and skip.release-cleanup verify — Phase 1 + 2 only. Report promotion status and the branch classification. No plan, no deletion.release-cleanup or release-cleanup dry-run — Phases 1-3. Verify, then print the prune plan. No deletion. (Default.)release-cleanup prune — Phases 1-4. Verify, print plan, confirm, then delete.If the user explicitly scopes the cleanup ("only worktrees", "local branches only", "skip remote"), honor it: still run verification, but restrict the plan and execution to the requested resource types.
Report:
STRANDED_*), if anyMERGED_NOT_YET_IN_PROD), if any-D was needed)development
Create an isolated git worktree from the correct base branch and check it out into a clean, gitignored directory. Use when the user asks to make a worktree, spin up a parallel/isolated workspace, work on something without disturbing the current checkout, branch off the current work, or run multiple agents on the same repo at once. Picks the base branch smartly — the current feature branch when you are on one, otherwise the develop integration branch — so worktrees continue your in-progress work by default instead of forking from the wrong place.
development
Structured "done coding, now what?" workflow: verify tests pass, detect the repository environment (normal repo vs worktree, named branch vs detached HEAD), present exactly the right merge / PR / keep / discard options, and execute the chosen path including safe worktree cleanup. Use when implementation is complete and the branch needs to be integrated, published, or abandoned.
tools
Capture a client or stakeholder feature request, turn it into a planner-ready PRD epic with scoped sub-issues, check for duplicate work, and place approved issues on a GitHub Projects kanban. Use when a user invokes feature intake, asks to turn a rough client requirement into GitHub issues, or wants an idea written as a PRD and pushed to a board.
tools
Provides Tailwind CSS v4 performance optimization and best practices guidelines. Triggers when writing, reviewing, or refactoring Tailwind CSS v4 code; when working with Tailwind configuration, @theme directive, utility classes, responsive design, dark mode, container queries, or CSS generation optimization.