| name | release |
| description | Cut a tagged release of ai-dev-baseline. Verifies release readiness and branch health live, stamps CHANGELOG.md, ships it as a PR, tags the exact merge commit it watched go green, publishes the GitHub Release from that tag's own message, and rolls the release milestone. This is the project-owned release role that decision |
| argument-hint | [--version vX.Y.Z] [--dry-run] |
| user-invocable | true |
| effort | high |
/release — cut a tagged release of this repo
This is a hand-written, project-owned skill. It is NOT generated by scripts/build.sh; there
is no base/workflows/release.md and there must never be one. Decision #3 / D7 resolved
that release execution stays project-owned because cutting has four incompatible shapes across
projects while the baseline can only ship one. The release-role section of
scripts/check-fact-drift.sh enforces that negative invariant against
base/workflows/release.md and agents/*/skills/release — this file
lives at .claude/skills/release/SKILL.md, which is the prescribed home for a project-scoped
skill (docs/per-project-overrides.md → Override 2a) and is deliberately outside the paths
that lint guards. Editing this file must never move it under base/ or agents/.
Why this skill exists
Before it, /roadmap would compute met and print Next: /release — a command that did not
exist here. The procedure lived as three prose sentences in CONTRIBUTING.md → Releases and
had to be hand-executed every time. A nonexistent slash command does not fail loudly in Claude
Code; it fuzzy-matches the nearest built-in (release-notes), so the miss was silent. See
issue #188.
Every decision below is delegated to a tested predicate that already exists in this repo.
This skill is the glue plus the two genuinely project-specific parts: the CHANGELOG format and
the tag convention.
What this skill does NOT do
- It never decides readiness itself.
roadmap-lib.sh release-ready and branch-health own
that; this skill refuses to proceed when they say no.
- It never force-pushes, never
--no-verify, never pushes to main directly. The CHANGELOG
stamp ships as an ordinary PR through the same gates as any other change.
- It never publishes a package or a deploy. It does publish a GitHub Release — step 11, since
#284 / D62, which reversed the tag-only decision this bullet used to state. What it still does
not do is push an artifact anywhere but that Release: no registry, no image, no deployment.
- It never creates or moves a tag while publishing.
publish refuses unless the tag is already
on origin, and passes --verify-tag so that gh cannot mint one from the default branch's head.
- It never edits the roadmap artifact.
/roadmap owns that. This skill's last act is
baseline release roll, which mutates milestones only.
How this skill runs
The procedure is a script, not prose. ./release.sh holds every step; this file explains what
each one means and what you must decide. That split is deliberate and was earned: the procedure
lived in fenced shell here for three review rounds and produced 27 defects — 10, then 7, then
10, and the third round's were mostly regressions introduced while fixing the second.
The reason is structural, not carelessness. A Markdown code block is a program nothing can run:
shellcheck never sees it, the tests never execute it, and the shell-session contract is invisible
(this repo has two opposite ones — base/workflows/roadmap.md's blocks share nothing,
base/workflows/cleanup.md's share everything). Every class of defect round 3 found is impossible
in a script: positional arguments are real arguments, cross-step values are real state, and a
GNU-only sort -V fails the moment a test runs it on macOS.
scripts/check-release-skill.sh covers both halves and runs in selfcheck + CI.
Steps
Run these in order. Each is a separate command; state carries in .claude/state/release-run.env,
so an interrupted release is resumable and a failed tag push can simply be retried.
| # | Command | What it does / what you decide |
|---|
| 1 | bash .claude/skills/release/release.sh preflight | tools, [roles].release guard, fetch --tags, clean tree, and default branch equal to origin (not merely fast-forwardable) |
| 2 | … release.sh readiness | resolves the roadmap artifact + release milestone, pins their identities, and refuses anything but met |
| 3 | … release.sh inventory | prints every version already used and the previous release, plus the ### sections in [Unreleased] — you choose the version from this |
| 4 | … release.sh version-guard vX.Y.Z | refuses an empty release, a malformed/reused/non-increasing version; records it. Exit 3 = RESUME (a local-only tag from a failed push — skip to step 10) |
| 5 | … release.sh roll-preflight | dry-runs the milestone rollover before anything irreversible |
| 6 | (you) branch + edit CHANGELOG.md, then … release.sh stamp-verify | see The stamp below |
| 7 | (you) bash scripts/selfcheck.sh, commit, push, open the PR, then … release.sh record-pr <n> | commit chore(release): stamp CHANGELOG for <VERSION>; PR body names the post-merge steps; Refs #<roadmap> — never a closing keyword |
| 8 | … release.sh await-review | waits for the declared reviewer, then settles the reviewed head's check set and records it |
| 9 | … release.sh merge → … release.sh verify-merge | re-verifies readiness against the pin, merges pinned to the reviewed head, then settles + health-checks the merge commit |
| 10 | write the tag message to a file, then |
The version
You pick it from step 3's output, by this repo's rule: a ### Removed or any breaking change to
the install contract → major; any ### Added → minor; only non-breaking ### Fixed /
### Changed → patch. Confirm with the operator before step 4 — a pushed tag is permanent.
The stamp
On a release-<VERSION> branch, edit CHANGELOG.md:
- Insert
## [X.Y.Z] - YYYY-MM-DD immediately below ## [Unreleased], leaving
## [Unreleased] in place and empty. Use today's date, resolved live.
- Add a 3–6 line paragraph under the new heading saying what the release is — the only prose a
reader of the tag will see.
- Repoint the link refs, descending:
[Unreleased] → compare/vX.Y.Z...HEAD, plus a new
[X.Y.Z] → compare/<previous>...vX.Y.Z. Only the first tag ever uses releases/tag/.
stamp-verify then asserts all of it whole-line and fixed-string.
The tag message
Write it to a file with the Write tool — version on line 1, blank line, the same paragraph used in
CHANGELOG.md, then the provenance line. Pass that path to release.sh tag --message-file.
It is a file, never an inline -m: the paragraph is Markdown and routinely contains backticks,
and inside a double-quoted -m the shell would run each backticked fragment as command
substitution — executing changelog text and corrupting the message.
The tag is created with --cleanup=verbatim, and that is not cosmetic. Git's default cleanup
treats a #-leading line as commentary and deletes it — in Markdown that is a heading, i.e.
ordinary content. Measured: an 80-byte message containing one # A markdown heading line was
stored as 54 bytes, with that line simply gone and nothing said. Verbatim also makes the stored
annotation byte-identical to your file, which is what lets step 11 read the notes back out of the
tag instead of depending on a file that may not exist by then.
The release
release.sh publish creates the GitHub Release for the tag step 10 just pushed. You run it, the
same way you run the tag step — it is the second irreversible, outward-facing act in this procedure,
and it gets the same confirmation the first one does. --dry-run prints exactly what it would do
(state, commit, notes size, asset digests) and makes no GitHub write and no run-state write. It
is not inert: like every other subcommand it runs git fetch --prune --tags origin first, so local
remote-tracking refs and tags are updated. Say that rather than "mutates nothing", which is the kind
of claim that is false in exactly one place nobody checks.
-
The notes are the tag's own message, byte for byte. Not the changelog, not a regenerated
summary: publish reads the annotation off the tag object itself — by byte offset, so a message
stored without a final newline does not silently gain one, and a CRLF message is preserved as it
is. The tag is the one place that prose durably lives, so a backfill years later still has it.
Three things make "byte for byte" hold rather than merely be asserted: --cleanup=verbatim when
the tag is written, an identity check when a pre-existing tag is resumed (its annotation must
equal the message file, and a lightweight one is refused), and a raw comparison of the published
body — carriage returns are not normalized away, they are diagnosed separately.
-
It publishes the commit the tag points at, re-verified against the MERGE_SHA this run
pinned in step 9, and re-checked to be an ancestor of the default branch.
-
Two assets: ai-dev-baseline-<X.Y.Z>.tar.gz — git archive of the tagged tree — and
SHA256SUMS. Regenerate and check either of them with:
git -c tar.umask=0022 archive --format=tar --prefix=ai-dev-baseline-X.Y.Z/ vX.Y.Z^{} > a.tar
gzip -n -9 -c a.tar > ai-dev-baseline-X.Y.Z.tar.gz
-c tar.umask=0022 is part of the command, not an optimisation. git archive takes mode
bits from that setting, it is configurable per-repo and per-user, and the driver pins it — so a
maintainer whose config differs would regenerate a different archive and conclude a perfectly
good release was corrupt. A verification procedure that can report a false mismatch is worse than
none.
git archive fixes every entry's mtime, uid, gid and mode from the commit, and gzip -n drops
the name and timestamp — so the bytes are the same on every run of the same gzip. Across other
gzip implementations they may not be, which is exactly why the digest is published rather than
the property merely asserted. What goes in the artifact is #285's decision; this step owns only
that a checksummed, regenerable one is published at all.
The operational consequence, so it is not a surprise: verification re-builds the artifact and
compares digests, so re-running publish against an already-published release from a machine
with a different gzip can refuse a release that is perfectly fine. That is the fail-closed
direction on purpose — the alternative is blessing a digest nobody checked — but if you hit it,
re-run from the machine that cut the release rather than clobbering the asset.
Backfilling an older tag — … release.sh publish --version vX.Y.Z — publishes a release for a
tag pushed long ago. It pins on the peeled remote tag rather than run state (a historical tag
has no VERSION/MERGE_SHA; roll deleted them), defaults to not Latest, and writes nothing
back to the run state, so it is safe to run while another release is in flight. A lightweight
tag is refused: it carries no message, and the message is the notes. v1.0.0 is lightweight, which
is why it has no release.
What this skill does NOT do
- It never decides readiness or health itself —
roadmap-lib.sh owns both, and the driver
refuses when they say no.
- It never force-pushes, never
--no-verify, never pushes to the default branch. The stamp
ships as an ordinary PR through the same gates as any other change.
- It never publishes a package or a deploy, and it publishes a GitHub Release only from a tag
that is already on origin. This bullet used to say the opposite — that the repo versions by git
tag only and that adding a publish step would be a decision change. #284 / D62 is that decision
change: four tags existed with zero Releases, so there were no release notes a human could read
and no artifact this project vouched for — the two things a bare tag does not carry.
- It never consults
pr-review.sh gate. That guard reads one surface of three, so the Codex
connector's clean pass — a +1 with no review object — wedges it at 16 forever (#167,
reproduced live on PR #187). It uses pr-watch.sh, which reads all three.
- It never moves or deletes a pushed tag. If the wrong SHA got tagged, cut a new patch version.
Failure recovery
| Interrupted after | Resume by |
|---|
| step 4 | re-run from step 6 |
| step 6 | re-run from step 7 |
| step 8 | re-run from step 9 |
| step 9 (merged, untagged) | re-run verify-merge, then step 10 |
| step 10 (tag created, push failed) | re-run release.sh tag --message-file <path> — it is idempotent, and version-guard exits 3 to route you here |
| step 10 (tag pushed, no release) | run release.sh publish — the tag is the input, so nothing is lost |
| step 11 (upload interrupted → a draft exists) | re-run release.sh publish — it uploads only what is missing, then publishes the draft |
| step 11 (release published) | run step 12 — the easy one to forget; without it /roadmap re-emits the cut forever |
| step 11 skipped, step 12 already run | the run state is gone, so use the backfill path: release.sh publish --version vX.Y.Z |