| name | release |
| description | Cut an apexyard release — diff dev↔main, pick semver bump, generate CHANGELOG, open release PR, auto-tag on merge. |
| argument-hint | [--dry-run] [<version, e.g. v1.2.0>] |
| allowed-tools | Bash, Read, Write |
/release — Cut an apexyard release
Standardises the dev → main release flow introduced by AgDR-0007. Reads the conventional-commit log between main and dev, proposes a semver bump, generates and writes the CHANGELOG entry, opens the release PR (dev→main), and triggers the auto-tag-on-release-pr-merge GitHub Actions workflow that tags the squash commit and creates a GitHub Release after merge. One command drives the operator from "nothing" to "PR open, ready for Rex + CEO". The tag and GitHub Release entry are created automatically by CI when the PR merges. The release PR also records a Released-From trailer with the exact dev cut-point SHA, and the changelog step warns loudly if commits inside the resolved changelog range aren't all making it into entries — both guard against the #872 changelog-truncation failure mode. Design rationale: AgDR-0076, AgDR-0094.
This skill is framework-only — it's for cutting apexyard releases, not for releasing managed projects under governance. Managed projects stay trunk-based and don't have a release-cut flow.
Usage
/release # auto-detect bump from conventional commits
/release v1.2.0 # explicit version, skip auto-detect
/release --dry-run # preview changelog + PR body without writing any files
/release --dry-run v1.2.0
Process
1. Pre-flight
Verify:
- Current repo IS the apexyard framework (origin or upstream is
me2resh/apexyard). Refuse otherwise — this skill is framework-only.
- Working tree is clean. Refuse if uncommitted changes.
dev branch exists (git rev-parse --verify upstream/dev). Refuse if absent — adopt the dev/main model first.
dev is ahead of main by ≥ 1 commit. Refuse if equal — nothing to release.
2. Pick a version
If <version> arg was passed, use it (must match v\d+\.\d+\.\d+).
Otherwise auto-detect from the conventional-commit types in git log upstream/main..upstream/dev:
| Found | Bump |
|---|
Any commit subject starts with feat!: / feat(...)!: / <type>!: (breaking marker) | MAJOR |
Any feat: / feat(...): (and no breaking) | MINOR |
Only fix: / chore: / docs: / refactor: / test: / style: / perf: / build: / ci: (and no feat: or breaking) | PATCH |
Read the current latest tag:
PREV_TAG=$(git describe --tags --abbrev=0 upstream/main 2>/dev/null \
|| gh api repos/me2resh/apexyard/releases/latest --jq '.tag_name' 2>/dev/null \
|| echo "NONE")
Bump accordingly. Show the user:
Current latest tag: vX.Y.Z
Proposed next: vA.B.C (MINOR — N feat commits, M fix commits)
Override? [Enter to accept, or type a version like v1.3.0]
3. Generate the CHANGELOG draft
Call the helper script bin/release-changelog.sh, which encapsulates the git log + conventional-commit grouping + PR-number extraction logic and is independently tested. Capture both its stdout (the changelog) and stderr (the resolved commit range) — the count-mismatch guard below needs both:
CHANGELOG_DRAFT=$(PREV_TAG="vX.Y.Z" \
HEAD_REF="upstream/dev" \
VERSION="vA.B.C" \
DATE="$(date +%F)" \
bash bin/release-changelog.sh 2>/tmp/release-changelog-range.txt)
echo "$CHANGELOG_DRAFT"
LOG_RANGE=$(grep -oE 'RELEASE_CHANGELOG_RANGE=.*' /tmp/release-changelog-range.txt | cut -d= -f2-)
if [ -z "$LOG_RANGE" ]; then
echo "ERROR: could not read RELEASE_CHANGELOG_RANGE from bin/release-changelog.sh's stderr (see /tmp/release-changelog-range.txt). The count-mismatch guard has nothing to check against — do NOT proceed until this is resolved." >&2
exit 1
fi
#1076 — a Closes bullet is now emitted ONLY from a recognised, same-repo conventional-commit scope. bin/release-changelog.sh previously (#1056, #1077) resolved an UNSCOPED commit's trailing squash PR number back to the issue it closes via a best-effort gh pr view --repo <repo> lookup of the PR's own body. That lookup mechanism (and the REPO_REMOTE / PR_LOOKUP_REPO env vars that configured it) has been removed entirely: it was itself a source of live wrong closes — a design-doc commit whose subject merely discussed several issues could close the wrong one, a cross-repo mention (other-repo#12) could be misread as a local issue, and a PR body that merely mentioned a closing keyword in prose could resolve to the mentioned number. The governing rule is now "prefer a MISSING close over a WRONG close" (see the script's own header): fix(#1042): ... closes #1042 directly from the scope, with no lookup and no network call; an unscoped commit, a cross-repo scope (docs(owner/repo#N): ...), and a revert commit all emit no Closes line at all.
The helper emits markdown to stdout in the format:
## [vA.B.C] — YYYY-MM-DD
Minor release — N features, M fixes.
### Added (feat)
- (#NN) <subject> — <short-sha>
...
### Fixed (fix)
- (#NN) <subject> — <short-sha>
### Changed (refactor / chore / docs)
- (#NN) <subject> — <short-sha>
### Breaking
- <only if breaking-marker commits exist>
### Closes
- Closes #N
- Closes #M
...
#1056 — one bullet per reference, not a comma list. GitHub's Closes keyword only auto-closes the reference immediately following it — a single Closes #N, #M, #P line only ever closed #N; everything after the first was silently inert. The generator now emits one - Closes #N bullet per reference so every one of them actually fires. #1076 narrowed which numbers are eligible in the first place: a same-repo scoped commit (fix(#1042): ...) uses the scope directly (that's the issue, by convention, no lookup needed); everything else — an unscoped commit (docs: ... (#1045)), a cross-repo scope (docs(owner/repo#N): ...), and a revert commit — emits no Closes line at all. A release with fewer Closes bullets than before #1076 is expected, not a regression: those refs are now correctly left for a human to close by hand rather than guessed at.
Count-mismatch guard (AgDR-0094, option D)
Immediately after generating the draft, sanity-check its entry count against the raw commit count in $LOG_RANGE — the same range bin/release-changelog.sh actually built the changelog from, captured in step 3 above. This is the cheap, always-on backstop behind the Released-From trailer (step 4) — it's what caught the v5.0.0 under-count by hand, and it stays even after the trailer exists as defence in depth against a mangled trailer or a trailer-less pre-AgDR-0094 tag.
#1002 — do not compare against upstream/main..upstream/dev. Under the release-cut model main only ever receives squash merges, so every individual dev commit stays permanently unreachable from main — main..dev grows monotonically with every release and can never shrink. Comparing the changelog's entry count against that raw, ever-growing number always false-positives (v5.2.0 cut: 402 raw commits vs. ~1-2 real entries, a "gap" of 400 on a perfectly correct changelog). $LOG_RANGE is anchored on the actual cut point (the Released-From trailer, or the #737 sync-boundary fallback) and is the range that matters:
#1017 — what this guard can (and can't) still see. RAW_COUNT and ENTRY_COUNT are now both derived from the same $LOG_RANGE, so the guard can no longer catch a truncated range the way it originally did — if $LOG_RANGE itself were too narrow, both counts would shrink together and the gap would stay small. That's an acceptable trade, not a silent regression: $LOG_RANGE's anchor is exact by construction once a Released-From trailer exists (AgDR-0094), and step 6's post-merge check now makes a missing trailer a hard failure — so the truncated-range case is closed at its source. What the guard genuinely still catches is classification drop-out inside a correct range: a commit that really is inside $LOG_RANGE but that bin/release-changelog.sh didn't turn into a changelog bullet (a subject it failed to classify, a filtering bug, etc.). The warning text below describes that condition, not the old truncated-range one — say what's actually being detected, not what the guard detected before #1012 re-anchored it.
RAW_COUNT=$(git rev-list --count "$LOG_RANGE") || {
echo "ERROR: 'git rev-list --count $LOG_RANGE' failed — the count-mismatch guard cannot evaluate its precondition. Do NOT proceed; fix \$LOG_RANGE (see /tmp/release-changelog-range.txt) and rerun." >&2
exit 1
}
ENTRY_COUNT=$(printf '%s\n' "$CHANGELOG_DRAFT" | grep -E '^- ' | grep -vcE '^- Closes ' || true)
[ -n "$ENTRY_COUNT" ] || ENTRY_COUNT=0
GAP=$(( RAW_COUNT - ENTRY_COUNT ))
TOLERANCE=5
if [ -gt ];
Migration script check (soft, #1105)
Immediately after the changelog draft, check whether this release carries a per-adopter migration script. This is the check docs/upgrading.md and .claude/skills/update/SKILL.md § 8b have documented since AgDR-0032 but that never actually existed here until #1105 — for 21 consecutive releases nothing flagged the omission, which is exactly how .claude/migrations/ ended up with scripts for only two of the ~30 release hops it should cover. This check is advisory only — it warns, it does not block the release. Making it a hard gate is a larger design decision than this fix covers (see Rule 13 below for why it's deliberately left as a follow-up, not shipped here).
MIGRATION_SCRIPT=".claude/migrations/${PREV_TAG}-to-vA.B.C.sh"
if [ ! -f "$MIGRATION_SCRIPT" ]; then
echo "⚠️ WARNING: no migration script found at $MIGRATION_SCRIPT."
echo " Every release should ship one — a real migration OR a no-op"
echo " placeholder — so /update's per-release migration chain"
echo " (.claude/migrations/, walked by _lib-migration-chain.sh) stays"
echo " walkable for adopters syncing across this release. See"
echo " .claude/migrations/README.md § 'Authoring a new migration'."
echo " If this release genuinely needs no adopter action, create the"
echo " no-op placeholder now (copy an existing one, e.g."
echo " v5.2.0-to-v5.3.0.sh, as the template) before opening the PR."
else
echo "✓ Migration script present: $MIGRATION_SCRIPT"
fi
Show the draft (and both warnings, if any) and let the user edit interactively before proceeding. On --dry-run, print the draft and stop here with:
Dry run — no changes made. Remove --dry-run to execute.
4. Prepare and push the release branch
Skip all of steps 4–5 on --dry-run.
git fetch upstream
DEV_SHA=$(git rev-parse upstream/dev)
git checkout -b "release/vA.B.C" upstream/dev
git add CHANGELOG.md
git commit -m "chore: release vA.B.C
- Prepend CHANGELOG section for vA.B.C
Refs #<release-ticket>
Released-From: $DEV_SHA"
git push upstream "release/vA.B.C"
#1004 — the trailer belongs in this commit message, not only the PR body. The release branch has exactly one commit, so this is the message a squash carries forward by construction — independent of how the merge is invoked. See step 6 for why the PR body copy alone used to silently lose the trailer.
5. Open the release PR
gh pr create \
--repo me2resh/apexyard \
--base main \
--head "release/vA.B.C" \
--title "release(#<release-ticket>): vA.B.C" \
--body-file /tmp/release-pr-body.md
PR body template (write to /tmp/release-pr-body.md before the gh pr create call). Interpolate $DEV_SHA (captured above) into the final line, matching the trailer already written into the branch commit in step 4. This copy is for human visibility on the PR page — this repo's squash_merge_commit_message setting is COMMIT_MESSAGES, so GitHub builds the squash commit's body from the branch's own commit messages, not this PR body, at merge time (#1004). The step-4 commit message is the authoritative copy the squash carries forward; keep this one in sync so a reviewer reading the PR sees the same trailer without having to check the branch commit:
<!-- multi-close: approved -->
## Summary
- **Releases vA.B.C** — see CHANGELOG section below for the full list of changes included in this release
- **CHANGELOG.md updated** — new section prepended at the top with grouped feat/fix/chore entries and PR refs
- **Auto-tag on merge** — `.github/workflows/auto-tag-on-release-pr-merge.yml` will tag the squash commit on main and create a GitHub Release entry automatically when this PR merges (AgDR-0076)
- **Release provenance recorded** — a `Released-From` trailer captures the exact `dev` SHA this release was cut from, so the next release's changelog range is deterministic instead of inferred (AgDR-0094, #872)
## CHANGELOG
<paste the draft from step 3>
## Migration script
- [ ] Does this release need a per-adopter migration? A migration script for this release — `.claude/migrations/${PREV_TAG}-to-vA.B.C.sh` (real migration OR no-op placeholder) — is included in this PR, and `docs/upgrading.md`'s "What each migration does" table has a matching row. See `.claude/migrations/README.md`. (Checked automatically by step 3's soft warning — see #1105.)
## Testing
1. After merge, confirm CI creates tag `vA.B.C` on `main` (check the `auto-tag-on-release-pr-merge` workflow run)
2. Verify `git describe --tags --abbrev=0 upstream/main` returns `vA.B.C`
3. Run `/release-sync vA.B.C` to sync main→dev and prevent squash divergence
4. Verify the squash commit's message carries the trailer: `git log -1 --pretty=format:'%(trailers:key=Released-From,valueonly)' vA.B.C`
Refs #
---
| Term | Definition |
|------|------------|
| Squash merge | GitHub merges all commits on the PR branch into a single commit on main; the branch HEAD is discarded and the resulting main tip has a new SHA |
| Auto-tag | The workflow fires on → + for branches, tags (the squash commit), and creates a GitHub Release |
| Ancestry guard | — fails if the tag would not be reachable from main, preventing a mis-placed tag like v2.3.0 |
| | The mandatory follow-up skill that merges main→dev after a squash-merge release, preventing SHA divergence accumulation |
| trailer | A git trailer ( in the commit message's final paragraph) recording the exact SHA this release was cut from — reads it back for the next release's changelog range (AgDR-0094) |
Released-From: $DEV
Why the trailer sits after the Glossary, as its own final paragraph: git interpret-trailers (and the %(trailers:...) pretty-format used by bin/release-changelog.sh) only recognises a trailer block when it is the LAST paragraph of the message — a blank line before it, nothing but Key: value lines after it. Putting Released-From: anywhere earlier (e.g. inside the Summary or Testing sections) would make it invisible to the reader on the next release cut. Do not add anything below the trailer line.
PR title format (release is whitelisted in pr.title_type_whitelist since #168):
release(#<release-ticket>): vA.B.C
6. Wait for review + merge (operator step)
The release PR runs through the normal flow:
- Code Reviewer (Rex) on the PR via
/code-review
- CEO
/approve-merge
- Merge gate green
- Squash-merge to
main
/release does not auto-merge. The CEO retains the discrete moment. The tag and GitHub Release are created automatically by the auto-tag-on-release-pr-merge.yml CI workflow after the merge.
#1004 — merge with an explicit subject + body, not a bare gh pr merge --squash. This repo has squash_merge_commit_message=COMMIT_MESSAGES (gh api repos/me2resh/apexyard --jq '.squash_merge_commit_message'), so GitHub's default squash body is built from the release branch's own commit messages, not the PR body. Step 4 already writes the Released-From trailer into the branch's sole commit, so a bare gh pr merge --squash would, in the common case, still carry the trailer through by construction. Don't rely on that alone — pass the reviewed PR body explicitly instead, so the merged commit is guaranteed to match what Rex and the CEO actually reviewed, independent of repo settings, a stray fixup commit changing the branch's commit count, or a future change to squash_merge_commit_message:
gh pr merge <pr-number> --repo me2resh/apexyard --squash \
--subject "release(#<release-ticket>): vA.B.C" \
--body-file /tmp/release-pr-body.md
/tmp/release-pr-body.md is the same file written in step 5 — its final paragraph is already the Released-From trailer, so this is the one merge command that keeps the trailer, the changelog, and the reviewed content all in sync on the squash commit.
Why not just change the repo's squash_merge_commit_message setting to PR_BODY? Considered and rejected: that's a repo-wide default that would change the squash body of every PR merged to this repo, not just releases — the least-targeted option from #1004's own candidate list. The explicit --subject/--body-file flags above override the default only for this one merge call, which is exactly the blast radius this fix needs.
Verify immediately after merge — do not skip:
git fetch upstream main
TRAILER=$(git log -1 --pretty=format:'%(trailers:key=Released-From,valueonly)' upstream/main)
if [ -z "$TRAILER" ]; then
echo "ERROR: squash commit on main has NO Released-From trailer." >&2
echo "The next release's changelog range will silently fall back to the" >&2
echo "#737 sync-boundary heuristic. Do not proceed to /release-sync until" >&2
echo "this is understood — check what merge command was actually used." >&2
exit 1
fi
echo "Released-From trailer confirmed: $TRAILER"
This is the loud-failure the trailer mechanism needs (#1004) — a missing trailer is otherwise invisible until the next release cut mis-anchors its range.
7. Tag + GitHub Release (automated via CI)
When the release PR is squash-merged to main, the .github/workflows/auto-tag-on-release-pr-merge.yml workflow fires automatically:
- Extracts the version from the branch name (
release/vA.B.C → vA.B.C).
- Uses
github.sha (the squash commit SHA — already the correct commit on main).
- Runs the ancestry guard:
git merge-base --is-ancestor <sha> main.
- Creates an annotated tag and pushes it with
git push origin --tags.
- Creates a GitHub Release entry from the CHANGELOG section in the PR body (in the same job — a tag pushed via GITHUB_TOKEN does not trigger a secondary release workflow).
No manual tagging required after merge. The workflow handles it.
Manual fallback (if CI workflow fails)
If the auto-tag workflow fails for any reason, follow the manual steps:
git fetch upstream
git tag vA.B.C upstream/main
if ! git merge-base --is-ancestor vA.B.C upstream/main; then
echo "ERROR: tag is mis-placed — delete and re-tag." >&2
exit 1
fi
git push upstream --tags
Post-tag release checklist
Verify all four assertions hold (the first three: CI workflow also checks these; the fourth: step 6's verification above, repeated here so it isn't lost if step 6 was skipped):
8. Confirm
Released vA.B.C — auto-tag workflow running on CI, will tag main + create GitHub Release.
N tickets auto-closed via the release PR.
Drift banner on adopters' forks will fire on next session.
Next: /release-sync vA.B.C
9. Open the main→dev sync PR (MANDATORY after every release)
Squash-merging dev→main creates SHA divergence: the squash commit on main is absent from dev, causing the next release PR to accumulate conflicts. Every release must be followed immediately by a sync-back PR.
Invoke:
/release-sync vA.B.C
This files a sync/main-to-dev-after-vA.B.C → dev PR that merges upstream/main into upstream/dev with -X ours, making the squash commit an ancestor of dev. The skill is idempotent — if main and dev are already in sync it exits 0 without creating a PR.
Do not skip this step. The v2.0.0 release suffered 99 merge conflicts because accumulated sync-back skips were not addressed for multiple release cycles (#403).
Rules
- Framework-only. Refuse to run on a managed project. The dev/main split is apexyard-the-framework's pattern, not the portfolio's.
- Pre-flight every check in step 1 — never proceed past a dirty tree, missing dev branch, or zero-commit delta.
- Always show the bump for confirmation — auto-detection is a proposal, not a fait accompli. The CEO's eyes are the final check on semver intent.
- CHANGELOG is editable before the release PR opens. Don't auto-file what hasn't been reviewed.
- Never auto-merge the release PR. Rex + CEO approval applies as for any PR. The skill stops at "PR opened."
- Never tag before merge, and never tag the release-branch HEAD. The auto-tag workflow handles tagging after merge, always using
github.sha (the squash commit). The manual fallback similarly tags upstream/main. See step 7 for the full guard.
<!-- multi-close: approved --> in the release PR body is required — release PRs legitimately close many tickets at once.
--dry-run stops before writing any files. The draft CHANGELOG section and PR body are shown; nothing is committed, branched, pushed, or filed.
- The
Released-From trailer must be its own final paragraph in BOTH the step-4 branch commit and the step-5 PR body (AgDR-0094). It is what makes the next release's changelog range deterministic — a trailer that lands mid-body (or gets pushed off the end by later edits) silently degrades back to the pre-AgDR-0094 sync-boundary heuristic, with all its known failure modes (#737, #872).
- Show the count-mismatch warning if it fires — a loud gap between
$LOG_RANGE's raw commit count and the changelog's entry count means commits inside that range didn't make it into changelog entries (#1017 — not a truncated range; both counts derive from the same $LOG_RANGE, so a truncated range can no longer produce this gap). Don't proceed past it without the operator explicitly confirming the range is correct. Never compare against upstream/main..upstream/dev (#1002) — under the release-cut squash model that range only ever grows, so it always false-positives; $LOG_RANGE (captured in step 3) is the range the changelog was actually built from. A $LOG_RANGE that fails to resolve or fails git rev-list must halt the release (exit 1), never pass silently (#1017) — see step 3's guard.
Related
AgDR-0007 — the release-cut branch model this skill enacts
AgDR-0076 — the automation design record (this enhancement)
AgDR-0094 — release provenance via the Released-From trailer + count-mismatch guard (#872)
bin/release-changelog.sh — the changelog generation helper script, independently tested
docs/release-process.md — the prose runbook (this skill is the automation; the doc is the manual fallback)
.github/workflows/auto-tag-on-release-pr-merge.yml — the CI workflow that tags the squash commit after merge
golden-paths/pipelines/auto-tag-on-release-pr-merge.yml — the reusable template for managed projects
.claude/skills/update/SKILL.md — the inverse skill, used by adopters pulling new releases into their fork
.claude/skills/release-sync/SKILL.md — the mandatory follow-up skill that syncs main back to dev after every release, preventing squash-divergence accumulation
Part of ApexYard — multi-project SDLC framework for Claude Code · MIT.