Release Cut
Take everything that has piled up on the long-lived release branch and cut a
single, well-scoped version. Replaces the "every tiny fix is its own release"
anti-pattern: PRs land on release via /pr-to-release, then /release-cut
batches them.
This composite never auto-fires. It only fires when the user explicitly invokes it. Other workflows may nudge the user that it is time:
next-priorityandsession-bootstrapshould surface a one-liner whengit rev-list --count main..releasereturns ≥ 5/pr-to-release's reconciliation block prints the same nudge
Preconditions (hard-fail if any miss)
releasebranch exists on originmainis fully merged intorelease(no divergence the other way)- CHANGELOG.md contains an
[Unreleased]section with at least one entry - Working tree is clean
- No open release-train PR already (
gh pr list --base main --head release)
Workflow
Phase 1 — Inventory (always)
git fetch origin --quiet
COMMITS=$(git rev-list --count main..release)
PRS=$(git log main..release --merges --pretty=%s | wc -l | tr -d ' ')
LAST_TAG=$(git tag --sort=-version:refname | head -1)
Show: count of commits, count of merged PRs, last tag, list of PR titles, current
[Unreleased] entries.
If COMMITS == 0: STOP with "Nothing to release. release is at the same point as main."
Phase 2 — Semver decision
Invoke version-bump in dry-run mode to propose patch / minor / major from
conventional commit history. Surface the proposal to the user; ask for override
only if any commit is marked BREAKING CHANGE and the proposal is not major,
or if the user explicitly requested a different bump.
Phase 3 — Promote changelog
Invoke changelog-update in promote mode: move [Unreleased] content under
a new [X.Y.Z] - YYYY-MM-DD header, re-add an empty [Unreleased] skeleton on
top. The promotion happens on a dedicated branch (chore/release-vX.Y.Z)
forked from release, not directly on release.
Phase 4 — Open the release-train PR
- Base:
main - Head:
chore/release-vX.Y.Z(which already contains every commit fromreleaseplus the changelog promotion + version bumps) - Title:
chore(release): vX.Y.Z - Body: full
[X.Y.Z]section copied verbatim from the new CHANGELOG, plus a list of every PR being shipped (git log main..HEAD --merges --pretty='- #%h %s').
Phase 5 — Gate the train PR
Invoke pr-merge-readiness against the release-train PR. Treat it like any
other PR: CI must be green, automated reviewers must be satisfied, no human
CHANGES_REQUESTED. On WAIT or FIX, hand back to user — never auto-fix a
release-train PR; the diff is too large to rebase blindly.
Phase 6 — Merge to main
- Method: merge commit (NOT squash). The whole point of the release branch is to preserve the individual PR shas in main's history.
- After merge: confirm
mainnow contains every release commit, and the version inpackage.jsononmainequals X.Y.Z.
Phase 7 — Tag + GitHub release
Invoke ship in tag-only mode against main:
- Create annotated tag
vX.Y.Zon the merge commit - Push the tag
- Create GitHub release using
[X.Y.Z]section as the release notes - Mark as
--latest
Phase 8 — Sync release back to main
git switch release
git merge --ff-only origin/main || git merge --no-ff origin/main -m "chore: sync release with main after vX.Y.Z"
git push origin release
release and main must end at the same commit. Any later PR that targets
release starts from a clean post-release base.
Phase 9 — Cleanup stale branches (always)
Find branches whose tip is now reachable from main:
git fetch origin --prune
# Local branches gone on remote
git branch -vv | awk '/: gone]/{print $1}' | xargs -r git branch -D
# Local branches fully merged to main (excluding main/release/protected)
for b in $(git for-each-ref --format='%(refname:short)' refs/heads); do
case "$b" in main|master|release|develop) continue;; esac
git merge-base --is-ancestor "$b" origin/main && git branch -D "$b"
done
# Remote feature branches merged via squash (titles match closed PRs)
gh pr list --state merged --base release --search "merged:>=$(date -u -v-30d +%Y-%m-%d)" \
--json headRefName --jq '.[].headRefName' \
| while read -r b; do git push origin --delete "$b" 2>/dev/null || true; done
Show count of branches pruned locally and remotely. Never delete main,
master, release, develop, or any branch matching release/*, hotfix/*.
Phase 10 — Deploy (optional, only if user confirms)
Ask: "Deploy vX.Y.Z now? (y/N)". On yes, invoke /ship-it starting at Phase 3
(deploy phase) — the version + changelog + tag work is already done.
Stop / escalation conditions
- Release-train PR has any failing check → hand to user, do not auto-fix
- Main↔release divergence cannot fast-forward and merge produces conflicts → surface to user with the conflict files listed
[Unreleased]is empty (no work to ship)- Tag
vX.Y.Zalready exists (someone half-cut the release earlier)
Reconciliation
RELEASE CUT — <repo> v<old> → v<new>
Commits batched: N (across M PRs)
Bump: <patch|minor|major> (proposed by conventional commits)
Train PR: #<n> — base=main, head=chore/release-vX.Y.Z
Merge: merge-commit at <SHA>
Tag: vX.Y.Z pushed, GitHub release published
Sync: release fast-forwarded to main
Cleanup: K local branches pruned, J remote branches deleted
Deploy: <deployed via /ship-it | skipped | scheduled>
Outputs / Evidence
- New version + tag + GitHub release URL
- Merge SHA on
main - List of PRs shipped in this version (for release notes / Slack)
- Branch cleanup count
- Nudge to deploy if not already deployed
Cadence guidance
When the nudge fires (≥5 commits on release beyond main), surface this in
session bootstrap output:
"
releaseis N commits ahead ofmainacross M PRs. Worth running/release-cutto batch these into one version?"
Do NOT auto-cut. The user controls cadence; the nudge just makes the cost of NOT cutting visible.
What this composite is NOT
- Not a hotfix → use
/hotfix(bypasses release branch) - Not a per-PR ship → use
/pr-to-releaseto land work onreleasefirst - Not a deploy-only step → use
/ship-itafter the release is cut, if needed
Pairs with
/pr-to-release— feeds work intorelease; this composite cuts the batch/hotfix— the only acceptable bypass of this cadence/ship-it— optional Phase 10 deploy