Release plugin
Cuts a release one step at a time: the prechecks, the changelog entry, the version bump, the pull request and its checks, the merge, the wait for the merged commit to go green, and the tag that publishes. It stops at the first refusal and says what refused.
A release that goes wrong is on a registry forever, so nothing here is improvised. What each step needs comes from .releasetools.yaml, which means the same command releases a Python package, a Rust crate and one plugin of a monorepo.
The steps are commands, not a script
Each one is a command you can read the output of. An agent that runs them says what went wrong; a script that wraps them turns a failure into an exit code and a person into a reader of logs. That is the whole reason this is a plugin.
| 1 | rt release::prechecks: version shape, clean tree, tag free, after the newest release, on the branch, not on the registry |
| 2 | branch from origin/<branch>, /release-notes:prepare, rt version::bump, commit, push |
| 3 | the pull request, with the changelog entry as its body, and its checks watched |
| 4 | merge, pull, and wait for the merged commit to go green |
| 5 | tag, push, and watch the publish workflow |
What the repository declares
projects:
- path: ./
manifest: pyproject.toml
changelog: CHANGELOG.md
bump: uv version {version}
release:
branch: main
merge: squash
checks: tests.yml
publish: publish.yml
registry: https://pypi.org/pypi/my-package/{version}/json
| key | what it is | default |
|---|---|---|
branch | what a release is cut from | main |
merge | how the pull request lands: squash, rebase or merge | squash |
checks | the workflow that must be green on the merged commit | none, and the wait is skipped |
publish | the workflow the tag starts, watched to the end | none, and it stops at the push |
registry | a URL that must 404 before releasing | none |
Those keys are the releasetools conventions rather than this plugin's own, so the versions guard and the release read the same file. {version} is the bare version everywhere, with no v.
registry names the distribution as the registry knows it, which is not always the repository's name or the package a reader imports. A URL built from the wrong one checks somebody else's package and passes.
The tag's shape is not declared. A repository releasing as one thing tags v<version>, and one whose projects version independently tags <project>/v<version>, which the conventions settle rather than leave to a key.
Why the merge defaults to a squash
It is the only strategy a protected branch always takes. GitHub replays the author's commits unsigned on a rebase merge, so a branch requiring signed commits refuses it, and a branch requiring linear history refuses a merge commit. A squash is one commit, signed by GitHub's web-flow key, and its subject is the pull request's.
Declare rebase where you want the branch's own commits and their messages, or merge where you want the branch kept as a branch, and where the branch protections allow either.
The tag lands after the merge, not before
A merged commit is a tree neither the branch nor the base has tested on its own. So step 4 waits for checks to finish on that commit, and only then does step 5 tag it.
That is the difference between a tag that names a commit somebody proved and one that names a commit somebody hoped about. The pull request's last green check was about a different tree.
Nothing here parses a manifest
Every ecosystem ships the command that sets a version, so the project declares which one it is and releasetools/cli runs it:
bump: uv version {version}
bump: npm version {version} --no-git-tag-version
bump: cargo set-version {version}
rt version::bump substitutes {version}, runs it, and refuses where the manifest did not take it. A manifest already declaring the version is left alone, so a resumed release does not double-bump.
Install
Claude Code and Codex install it from the marketplace:
claude plugin marketplace add releasetools/agent-plugins
claude plugin install release@release-tools
codex plugin marketplace add releasetools/agent-plugins
codex plugin add release@release-tools
Hermes and Antigravity clone the repository and read plugin.json at the plugin root, so neither needs a marketplace:
hermes plugins install releasetools/agent-plugins/plugins/release
agy plugin install https://github.com/releasetools/agent-plugins
Add --scope project to the Claude Code commands to declare the plugin in the repository rather than in your own configuration, so the next person to cut a release is offered the same one.
What it needs
releasetools/cli v0.4.0 or newer, which is where version::bump arrived:
brew install releasetools/tap/releasetools-cli
gh, authenticated, for the pull request and the workflows. The release notes plugin for the changelog entry, which step 2 calls rather than writing one itself.
Commands
| Command | |
|---|---|
/release:cut <version> [--path <dir>] | The five steps, in order, stopping at the first refusal |
/release:help | What the plugin does, and what it will not |
--path <dir> names which project of a monorepo is being released. A repository declaring several releases one of them at a time.
The version is yours to give. With none, it asks, and it never guesses one from the commits: a version guessed wrong is a version somebody has to notice.
What it will not do
Move a tag. A tag names one commit forever, and a version that needs a second attempt gets the next number. Where a publish workflow fails after it has already uploaded, re-run that workflow rather than re-tagging.
Force anything, stash your work, delete an unmerged branch, retry a failed check in the hope that it passes, or release a version nobody named.