/rhiza:release¶
Land a release through a pull request, in one run: bump every version location the repo declares, regenerate the changelog, open a release PR, let the forge merge it once its checks pass, and tag the commit that actually landed.
With no argument you get a table of candidate versions — patch, minor and major,
each with what it means and which commits point at it. It does not suggest one, mark
one recommended, or default to one: picking the bump is your call. Pass an explicit
vX.Y.Z to skip the table. An argument that isn't semver-shaped is treated as a comment,
not a target.
The repo declares where its version lives
/release does not scan for version-shaped strings. It runs
bump-my-version, which
reads [tool.bumpversion] from .bumpversion.toml or pyproject.toml and rewrites
only the explicit search/replace patterns listed there.
That's what makes it safe to bump a pyproject.toml version, a plugin manifest and
a self-referencing CI pin in one step without touching a dependency that happens
to share the current version number. If the config is missing, /release stops
rather than guessing — skeleton scaffolds it for new
repos.
What it does¶
- Preconditions — clean tree,
[tool.bumpversion]present (viabump-my-version show current_version), on the default branch (asks if not), tags fetched, and the commits being released actually on that branch. It does not require.rhiza/: nothing in the release flow comes from the template, which is why this works on the plugin repo too. - Gathers candidates — the
patch/minor/majorversions computed from the floor byplugin/scripts/check_version_bump.py, so every option offered is guaranteed legal, plus a count of the unreleasedfeat/fix/breaking commits as evidence for the table. - Prints them as a table — every legal candidate in ascending order, each labelled with its consequence and the commits pointing at it, then asks you to choose. No recommended row, no default, no ordering that implies one. See below for why.
- Guards the choice — via
plugin/scripts/check_version_bump.py. The floor is the greater of the declared current version and the highest existing tag, compared as semver (sov1.10.0beatsv1.9.0), and an existing tag is refused outright. A hand-typed value is guarded too. - Previews the release notes and stops if nothing is unreleased.
- Bumps every declared location —
bump-my-version bump --new-version, then showsgit diff --stat. - Prepends a
CHANGELOG.mdsection for the unreleased commits, labelled with the new tag, then checks the diff touches nothing else. See below for why it prepends. - Commits the bump on a release branch —
chore: release vX.Y.Z, on a branch from pr-base, pushed. No tag yet. - Opens the release PR.
- Hands the merge to the forge —
gh pr merge --squash --auto, orglab mr merge --squash --auto-merge --yes, throughplugin/scripts/platform_cli.py pr-merge. A repo with auto-merge switched off just says so; the run continues, because the next step doesn't care who merges. - Waits for the bump to reach the default branch —
plugin/scripts/wait_for_merge.pyfetchesorigin/<default>on an interval and reads the newest release heading out of that branch'sCHANGELOG.md, through the same parser the phase decision uses. It waits for the bump landing, not for a request merging, because that is what the tag needs to be true. Nine minutes per call, up to three calls while the PR's checks are still running. -
Tags the merged commit and pushes the tag — the commit the wait watched arrive, named by SHA rather than by branch position, so another merge landing in the same few seconds cannot decide what gets released. That push triggers the repo's
Releaseworkflow.The version is the human decision and the checks are the gate: you picked the bump from the table, and a PR titled
chore: release vX.Y.Zwent green before it merged. What keeps the tag safe is step 12's guard — a version that doesn't strictly increase, or a tag that already exists, stops the run before anything is created. 13. Reports the release, or — if the wait ran out — the open PR, with the tag explicitly not created.
Don't let CI tag it as well — this repo tried, and removed it
If the repo owns its CI, a workflow on push-to-default can tag a merged release
itself: when the declared version is ahead of the highest tag, tag the merged commit
and publish. That condition is one of the two /release uses to read the repo's state
— and it is blind on a tag-derived repo, where nothing is ever ahead of the tag — and
it is self-limiting: after tagging, declared equals highest, so every ordinary merge
that follows is a no-op. This repo ran exactly that, and removed it.
The reason still applies, and now applies more sharply: it does not replace the tag
step, it races it. The command tags the merge it just waited for, so a workflow doing
the same thing means two things creating one ref — whichever loses gets
Reference already exists (HTTP 422), a failed run, and a release that actually
succeeded sitting under a red check. A step that must happen exactly once needs exactly
one entry point.
Two further traps if you still want it: a ref pushed with GITHUB_TOKEN does not
trigger further workflow runs, so the auto-created tag publishes nothing unless the
publishing workflow is invoked explicitly (workflow_call) rather than left to its
tag-push trigger — and that second entry point then has to be maintained too.
Why there's a wait in the middle¶
The version bump is an ordinary change and goes through review like any other, which means the default branch is never pushed to directly and required checks actually gate the release. But a tag has to name a commit on the default branch, and a squash-merge replaces the branch's commits with a new one — so a tag cut before the merge points at a SHA that never lands.
No reordering fixes that: the commit worth tagging does not exist until the PR merges. It used to be you who bridged that gap, by running the command a second time. Now the run bridges it — it asks the forge to merge the PR when its checks pass, then waits for the bump to appear on the default branch.
The wait is bounded, and running out is an ordinary outcome. Each call waits nine
minutes — what fits inside one tool call — and /release repeats it only while the forge
says the PR's checks are still running, at most three times. A red check, or a green PR
nobody has merged, ends the wait immediately, because neither is something more waiting
fixes. Then the run reports the open PR and stops with no tag created, and re-running
/release finishes the release.
It knows where it stands from the repo itself — you never tell it. The question is "is a
version committed to the default branch that no tag names?", and
check_version_bump.py answers it, so the state is read off a phase line rather than
compared by eye:
| State | Meaning | What it does |
|---|---|---|
| declared version == highest tag, nothing pending | the declared version is released | phase A — bump, changelog, PR, merge, tag |
| a committed version > highest tag | a merged bump no tag names | phase B — tags the merged commit, target taken from the script |
| declared version < highest tag, or two sources disagreeing | reverted bump, a tag cut ahead, or a PR edited before merge | stops and reports the reason (exit 3) |
Those two names are states of the repo, not two runs of the command. An ordinary run starts in A, and its own merge is what puts the repo into B, which it then tags. A run that starts in B is one finishing a release whose wait had expired — and that verdict is the only state carried between runs, so the merge can happen days later, in a different session, and the next run still knows what to do.
--auto waits for required checks — and a repo with none merges at once
Auto-merge defers to whatever branch protection actually enforces. Where nothing is required, the release PR is opened and merged within the same run, and "one step" is literal. That is not the command being hasty: it is the repo having no gate, which was equally true before, just hidden behind the pause of waiting for a second invocation. If you want a longer look at a release PR, make a check required.
On GitLab the flag differs and so does its meaning: glab mr merge --auto-merge
defers only while a pipeline is already running, and merges immediately when none is.
Both platforms mean "merge when the forge will allow it"; only GitHub's is a gate.
Where the committed version is, when it isn't in a file¶
For most repos the declared version is the evidence: bump-my-version wrote a number
into a manifest, so it exceeds the highest tag exactly when the release PR has merged.
A tag-derived repo has no such number. When the version is the newest tag — Go and
Rust, whose synced .bumpversion.toml deliberately omits current_version, and any
Python project on hatch-vcs with dynamic = ["version"] — the declared version is
read from the highest tag, so it can never exceed it. Phase B was unreachable for those
repos by construction: the flow re-detected phase A after the merge and offered the same
menu again. No tag was ever mis-cut, but the second half of the release could not be run.
So CHANGELOG.md is the evidence instead. Step 7 prepends the new section on the release
branch, so after the merge its newest heading names a version above every tag — and on a
tag-derived repo the run refuses rather than assuming nothing is pending when it is
missing, because there is no third source to fall back on.
The one repo that can't use a PR¶
When CI stubs delegate via uses: <owner>/<repo>/…@vX.Y.Z and the repo being released
is that repo — the stub-pin case /release bumps in step 6 — the release PR
references a tag that does not exist yet. A cross-repo uses: resolves at Set up job,
before checkout, so every job dies before running anything:
The failures are indistinguishable from real breakage at a glance, yet nothing in the diff is at fault — the jobs never got as far as checking out code. Required checks can therefore never go green, and the PR is unmergeable except by bypassing branch protection.
/release detects the self-pin before opening a PR that cannot merge, and asks. The
fallback commits and tags on the default branch directly, pushing both refs with
git push --atomic origin HEAD <TAG> — atomic because two sequential pushes leave a
window where the branch is published and the tag is not, and every run started in it fails
as above.
On that path there is nothing to merge and nothing to wait for, so steps 10–11 are skipped: it is already a single run, and what it gives up is the pull request, not a second invocation.
The durable fix belongs in the repo, not here: reference your own action by local path
(uses: ./.github/actions/<name>), which needs no tag, cannot drift, and makes the repo
releasable by PR like every other.
Why it doesn't suggest a version¶
Deriving the bump from conventional commits looks decidable and isn't. git-cliff
--bumped-version will happily turn a single feat!: or BREAKING CHANGE footer at 0.x
into v1.0.0 — it applies no pre-1.0 special case. But at 0.x semver does not
require that: going to 1.0 is a deliberate statement of API stability, and it can only
be spent once. Nothing in the commit log records whether you're ready to spend it.
So /release gathers the evidence and stops there. The table shows every legal candidate
with the commits behind it — including, pre-1.0, both v1.0.0 and the minor candidate
with the trade-off spelled out — and the choice stays yours. A recommendation here would
be a guess wearing the clothes of a derivation, and the one that lands as an accidental
1.0 is not reversible.
Why the guard is a separate script¶
bump-my-version accepts a backwards version without complaint and knows nothing about
git tags — verified: 0.4.2 → 0.4.1 exits 0. Since a pushed tag is effectively
permanent, tagging backwards or reusing a tag is the one mistake in this flow that isn't
cheaply reversible, so that single check stays explicit and tested rather than assumed.
Why the changelog is prepended, not regenerated¶
git-cliff --output CHANGELOG.md rebuilds the entire file from the commits reachable
from HEAD. That reachability is the trap: a tag stops being reachable whenever its
branch is squash-merged, rebased, or deleted after release — all routine — and a
regeneration then drops that release's section entirely and re-files its commits
under the next reachable version. The output looks plausible, the diff quietly rewrites
history that already shipped, and nobody re-reads old changelog entries at release time.
Releasing rhiza-hooks v1.1.0 hit it: the full regeneration deleted ## [0.7.0] and
moved its two commits into 0.7.1.
--unreleased --tag "$TARGET" --prepend CHANGELOG.md writes only the new section and
leaves everything below it byte-identical, which is why /release diffs the result and
refuses to commit a change that reaches further. One caveat that follows from prepending:
it is not idempotent, so a repeat run needs git checkout CHANGELOG.md first.
Anchor your pyproject.toml pattern¶
search/replace apply to every occurrence in a file, so the obvious form also
rewrites a [tool.something].version sharing the number. Confine it to [project]:
[[tool.bumpversion.files]]
filename = "pyproject.toml"
regex = true
search = '(?ms)^\[project\]((?:(?!^\[)[\s\S])*?)^version = "{current_version}"'
replace = '[project]\1version = "{new_version}"'
Dependency pins (httpx>=1.2.0) are never at risk either way — they aren't
line-anchored version = assignments — but a second [tool.*] table is.
Self-referencing CI stubs¶
Rhiza ships CI as thin stubs delegating via uses: <owner>/<repo>/.github/…@vX.Y.Z.
When the repo being released is the one those stubs point at, the pin must move with
the release, or the published tag ships workflows calling the previous version's
reusable workflows. That's one config entry per stub — and if /release spots such a
self-reference the config doesn't cover, it stops and says so. Third-party pins
(actions/checkout@v5) and floating refs (@main) are never rewritten.
Notes¶
- Never pushes to the default branch, and never force-tags. It pushes two things: the
release branch — the same thing
/rhiza:initand/rhiza:updatedo — and then the tag, onto the commit the forge merged. Before the merge, the whole thing is undone by deleting the branch; after the tag, by deleting the tag. - The wait needs no forge CLI. It is
git fetchagainstoriginand a read of the mergedCHANGELOG.md, so a repo whosegh/glabis missing or logged out still gets it — you merge the PR in the browser and the run tags what lands. - Works for this plugin too. It reads the version from wherever the config points,
so a repo with no
pyproject.toml(like this one, whose version lives in the two.claude-plugin/manifests) is handled the same way. - This is the only release path. A
scripts/release.shused to duplicate it for agent-free use, from the same[tool.bumpversion]config and the same guard. It was removed once this command dropped its.rhiza/requirement and moved tobump-my-version, because from that point the two did the same work — and the shell copy was the one executable in the repo with no test and noshellcheckhook, while every bundled script is gated at 100% coverage. - Needs
uvxforbump-my-versionandgit-cliff, andgh/glabto open the PR — if the forge CLI is missing the branch is still pushed, so you can open it by hand. - Publishing manually (only needed when the repo has no release workflow) goes through
plugin/scripts/platform_cli.py release-create. On GitLab--notes-fileis required:glabhas no--generate-notes, so the mapper refuses rather than publishing a release with empty notes.
Reference¶
| Source | plugin/skills/release/SKILL.md |
| Invocation | /rhiza:release [version e.g. v1.4.0] (optional; omit to pick from a table of candidates) |
| Model-invocable | no — excluded from model invocation |
| Allowed tools | Bash(git*), Bash(gh*), Bash(glab*), Bash(uv*), Bash(uvx*), Bash(make*), Bash(cat*), Bash(grep*), Read, Edit, AskUserQuestion |