@entro314labs/release-kit
v2.11.0
Published
Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release
Maintainers
Readme
🚀 release-kit
Single-file, zero-dependency release automation for JS/TS/Node projects.
version bump → changelog → commit → annotated tag → push → publish → GitHub release
release.mjs imports nothing but node:*. No dependencies, no build step, no config
required — the file is the tool. That is why it can be installed as a package, run
straight from the registry, or vendored into a project as a plain file, with no difference
in behaviour between them.
$ pnpm release minor
acme-toolkit release
2.4.0 → 2.5.0 tag v2.5.0 dist-tag latest
[1] Preflight
ok version 2.4.0 → 2.5.0
ok working tree clean
ok on main
ok remote origin
ok up to date with origin/main
ok tag v2.5.0 is free
ok gh authenticated (octocat)
ok npm authenticated (octocat)
ok CHANGELOG.md: [Unreleased] will become [2.5.0]
[2] Write version 2.5.0
[3] Roll CHANGELOG.md to 2.5.0
[4] Commit
[5] Annotated tag v2.5.0
[6] Push branch and tag to origin
[7] Publish to the registry (dist-tag latest)
[8] GitHub release v2.5.0
Released v2.5.0Contents
| | |
| ----------------------------------------------------------------------- | --------------------------------------- |
| 📦 Install | package, npx, or vendored file |
| ⚡ Usage | targets, bumps, flags |
| 🧩 Steps | the seven steps and how to select them |
| 📚 Libraries versus apps | which steps you want, and why |
| 🪝 Hooks | commands between the steps |
| 🤖 Assistant | optional AI drafting |
| 🌍 Any language | Rust, Python, tag-only, anything |
| 🚂 Release trains | monorepos and multi-repo workspaces |
| ✅ Preflight | what is checked before anything mutates |
| ♻️ Recovering from a failed run | why re-running is safe |
| ⚙️ Configuration | release.config.json, publishing, auth |
| 🔄 Keeping vendored copies in sync | --sync |
| 📋 Requirements | Node, git, gh |
📦 Install
Pick by what the project is, not by preference.
Node projects — devDependency
Pins the version, so every machine and CI run behave identically.
pnpm add -D @entro314labs/release-kit{ "scripts": { "release": "release-kit" } }Non-Node projects — global install
A Rust, Python or Go repository has no manifest to hang a devDependency on, so install it once and use it everywhere.
npm i -g @entro314labs/release-kit
release-kit minorCI, any language — pinned npx
No global state to drift, no install step, and the version is explicit in the command.
npx @entro314labs/[email protected] minor --yesVendored — no registry at release time
For a project that should not depend on the registry it is about to publish to, or that needs releases to work offline. The file is self-contained, so a copy is a complete install.
npx @entro314labs/release-kit --sync . # writes scripts/release.mjs{ "scripts": { "release": "node scripts/release.mjs" } }Piped — nothing installed at all
release.mjs runs straight from stdin, arguments and all. Useful for a one-off release on a
machine you do not want to install anything on.
curl -fsSL https://raw.githubusercontent.com/entro314-labs/release-kit/v2.3.0/release.mjs \
| node - minor --yesPin the URL to a tag, never main: piping an unpinned remote script into an interpreter
means whatever is at that URL runs against your repository and your credentials. --sync is
the one thing that does not work this way — copying itself needs a file on disk.
All five paths run the same file and need Node 24+. That includes the Rust, Python and Go projects:
release-kitis a Node program regardless of what it is releasing.
Zero-config works on the conventions below; add a release.config.json
only for what differs.
⚡ Usage
pnpm release # release the version already in package.json
pnpm release 2.3.0 # release an explicit version
pnpm release minor # bump from the current version
pnpm release prerelease --preid beta
pnpm release -- --dry-run # print every step, execute nothing
pnpm release -- --helpnext answers "what version would this release?" and stops. Only the version reaches
stdout, so it substitutes into a command; everything it would otherwise narrate goes to
stderr, where a human still reads it.
release-kit next # the version already in the manifest
release-kit next minor # what a minor bump would release
release-kit next auto # what the commits imply — the same inference the release usesThe target is optional. With no target it releases whatever version package.json
already says — which is the mode to use when a version bump landed in an earlier commit.
| auto | derived from the Conventional Commits since the last tag |
auto follows release-please's rules: a breaking change (! or a BREAKING CHANGE:
footer) is a major, a feat: is a minor, anything else is a patch. Below 1.0.0 that is
softened — a breaking change bumps the minor rather than jumping to 1.0.0. A commit body
containing Release-As: 2.0.0 pins the version outright. It always prints what it inferred
and why before doing anything. Set "versioning" to always-patch, always-minor or
always-major to never infer. A Notes: line in a body sets what the release notes say
about that commit — see Release notes.
| Target | From 1.2.3 | From 2.0.0-beta.1 |
| ------------------------------------ | ------------------------------------------------ | ------------------- |
| (none) | 1.2.3 | 2.0.0-beta.1 |
| patch | 1.2.4 | 2.0.0 |
| minor | 1.3.0 | 2.0.0 |
| major | 2.0.0 | 2.0.0 |
| prerelease | 1.2.4-beta.0 | 2.0.0-beta.2 |
| prepatch / preminor / premajor | 1.2.4-beta.0 / 1.3.0-beta.0 / 2.0.0-beta.0 | same |
| 2.5.0 | 2.5.0 | 2.5.0 |
A major/minor/patch bump off a prerelease releases that prerelease's base version
when the base already satisfies the bump, so promoting a release candidate is a plain
patch. The arithmetic matches semver.inc
exactly, and precedence follows the SemVer spec.
Prerelease bumps need --preid unless the current version already carries one to infer.
Flags
| Flag | Effect |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --only <steps> | Run only these steps, comma-separated. |
| --skip <steps> | Run every step except these. |
| --commit | Force the commit step on when a steps config removed it. |
| --package | Release the package in this directory, one of several in the repository: its release.config.json, manifest, changelog and publish; commits and working tree limited to the directory; tagged <name>@<version> unless tagPrefix is set. release-train passes it. Without it, a nested package is refused. |
| --dry-run | Print every step, execute nothing. Preflight still runs and still reports. |
| --yes, -y | Skip the confirmation prompt. |
| --preid <id> | Prerelease identifier for a pre* bump: alpha, beta, rc, next, nightly, canary. |
| --dist-tag <name> | Override the npm dist-tag. Always wins over the derived one. |
| --notes <source> | Where notes come from: auto, changelog, assistant, commits, github. |
| --notes-file <path> | Write the resolved notes to a file for the next tool — see notesFile. |
| --assistant <name> | Drafting CLI: auto, none, claude, codex. |
| --assistant-model <name> | Model the assistant runs with. |
| --assistant-effort <level> | Reasoning effort the assistant runs with. |
| --sync <dir>... | Copy this script into other projects and exit. Touches no git state. |
| --help, -h | Full flag list. |
Linting commits
A subject that is not Conventional Commits is invisible: it contributes nothing to the
inferred bump and never reaches the changelog. lint-commits checks subjects against the
same parser the release uses, so the gate and the release can never disagree about what
counts.
release-kit lint-commits # since the last tag
release-kit lint-commits main..HEAD # an explicit range
release-kit lint-commits --subject "feat: x" # one subject — a pull request titleIt exits non-zero on a subject the release cannot read. A type outside the changelog table
— security:, i18n: — is a warning, not a failure: those still get printed, under Other
Changes. Release commits, merges and fixup!/squash! markers are skipped, per the same
ignoreCommits config the release notes use.
Local commit-msg hooks cannot cover the case that matters most. If you squash-merge, the commit released is the pull request title, which no hook ever sees — check it in CI:
- name: Lint the pull request title
env:
TITLE: ${{ github.event.pull_request.title }}
run: npx @entro314labs/[email protected] lint-commits --subject "$TITLE"Pass the title through env, never through ${{ }} inside run: — a pull request title is
attacker-controlled text and interpolating it into a shell command is a script injection.
🧩 Steps
A release is seven named steps. They always run in this order — steps selects which of
them execute, it never reorders them.
| Step | Default | What it does |
| ----------- | ------- | -------------------------------------------------------------------------------------------------- |
| commit | on* | Commit a dirty working tree — drafted with an assistant, generated without |
| version | on | Write the version into package.json and versionFiles |
| changelog | on | Roll [Unreleased] into the version, or add drafted notes |
| tag | on | Annotated git tag carrying the release notes |
| push | on | Push the branch and tag as one transaction (--follow-tags --atomic) |
| publish | on | Run the configured publish command |
| release | on | Create the GitHub release |
* commit no-ops on a clean tree. On a dirty tree it stages everything and commits:
with an assistant configured the message is drafted from the
diff; without one it degrades gracefully to a generated chore: message naming the
changed files — a release is never blocked because a text generator was unavailable.
Opt out with --skip commit or a steps config, which restores the refusal on a dirty
tree.
version and changelog write files; those writes are persisted by a release commit made
automatically when either step runs.
release-kit minor --skip publish # everything but publish
release-kit --only tag,push,release # a version already committed elsewhere
release-kit minor --skip commit # never touch uncommitted workOr fix it per project, and just run release-kit minor:
{ "steps": ["commit", "version", "changelog", "tag", "push", "release"] }steps decides what runs — and an explicit list is complete: a step not named does
not run, including commit. A steps list written before commit became a default step
therefore opts out of it without having chosen to; add "commit" to the list (as the
examples here do), or pass --commit for one run. Every other key describes how a
step behaves — publish
is the command, changelog is the file. A step whose configuration is null runs as a
no-op and says so, rather than silently meaning "skip".
Release notes
Notes resolve in this order:
- The
CHANGELOG.mdsection for the version being released. Every common heading shape is recognised:## [1.2.3] - 2026-08-17,## v1.2.3,## 1.2.3 (2026-08-17). The section ends at the next##heading or---rule. - The
## [Unreleased]section, if the version has no section of its own — this is the same content that step 2 above is about to promote. - The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
Improvements, Reverts, Dependencies, Documentation, Code Refactoring, Build System,
Continuous Integration, Tests, Styles, Miscellaneous Chores — with breaking changes
first. Every type is reported, since a changelog is a record; list the ones to leave out
in
hiddenTypes—["chore", "ci", "docs"], say, for a shorter list. A type outside the table, such assecurity:, lands under Other Changes. Each bullet links to its commit, andcloses #12/fixes #34in a message becomes a link to the issue. ABREAKING CHANGE:footer is used in place of the subject, since it explains the break. A commit reverted within the same release drops out along with its revert. A New Contributors section names anyone whose first commit to the repository is in this release — derived from the git history rather than a forge API, so it needs no token and works offline, and it is skipped on a first release where everyone would be new. All deterministic and needing nothing installed, so decent notes are the default rather than something that requires an assistant. - Otherwise GitHub generates them from the commits since the previous tag.
A Notes: line in a commit body is the author's wording for the entry. Commit-derived
notes use it in place of the subject (and in place of a BREAKING CHANGE: footer), and an
assistant is handed it with the commit and told to keep it.
Notes: no-notes leaves the commit out of the notes entirely — both kinds — for work that
has to be a fix: or feat: for the version bump but is not news to anyone upgrading. The
bump itself is unaffected: the trailer decides what is said about a change, not whether it
counts.
fix(ui): raise the badge z-index above the progress bar
Notes: The pull request badge stays visible while a push is in progressWhich tag the history is read from is the highest version tag carrying the configured
prefix that is reachable from HEAD — not the nearest tag. A repository carrying tags that
are not releases (a rolling latest-beta marker, a nightly) is unaffected by them, and a
patch tagged on top of a later minor does not drag the baseline backwards.
A stable release absorbs the candidates that led to it. Releasing 2.0.0 after
2.0.0-rc.1 and -rc.2 reads history from the last stable tag, so the notes describe
everything the release ships rather than the gap between the last two candidates — which
is usually just the release commit. Releasing a candidate is unchanged: each one's notes
say what changed in that candidate. The candidates' own changelog sections are not a source
for the stable release: its notes are generated from those commits, so wording edited into
a [2.0.0-rc.1] section does not carry over. The sections stay in the file, preflight warns
when it finds them, and writing the 2.0.0 notes into [Unreleased] before releasing is
how hand-written wording reaches the stable release.
--notes <source> forces one instead of walking that list: changelog, assistant,
commits, or github. A named source that produces nothing is an error rather than a
quiet fall-through — asking for one thing and being given another is worse than being told
it is unavailable.
--assistant names the tool; --notes names the source. Making an assistant available
does not make it preferred, because a hand-written changelog entry should still win.
The same text becomes the tag annotation, the GitHub release body, and (when rolled) the changelog entry. It is written once and lands in three places.
Keep a Changelog link definitions are maintained. ## [1.2.3] is a markdown link
reference, and renders as literal bracketed text without a matching definition at the
foot of the file. Every bracketed heading in the document gets one — not just the version
being released — so a changelog that never had them is repaired in one release: each
version compares against the version below it, the oldest links to its own tag, and
[Unreleased] compares the newest version against HEAD. Definitions for labels that are
not headings are left alone, since those are yours.
npm dist-tags
The dist-tag is derived from the version, never guessed:
| Version | dist-tag |
| ---------------------- | ------------------------------------------------------------- |
| 1.2.3 | latest |
| 1.2.3-beta.4 | beta (any of alpha beta canary next nightly rc) |
| 3.0.0-1751023456789 | canary (an all-numeric prerelease is a timestamp) |
| 1.2.3-experimental.0 | refuses to release |
The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
falling through to latest would put a prerelease on the stable line where every
npm install picks it up. Pass --dist-tag <name> to choose a channel explicitly.
✅ Preflight
Every check runs and every failure is reported before it aborts once with the whole list, rather than stopping at the first problem.
- The target version is greater than the current one — and for
auto, which bump the commits imply and why - The version step will write the target, when the target differs from what the files say
— a tag naming one version over a manifest carrying another is refused, since
npm publishsends the manifest - A relative bump is not counting from a version a dead run wrote and never committed
(the file says one thing on disk and another at
HEAD), and not skipping past a release tagged atHEADthat never reached the registry — both are refused with the command that finishes the earlier release instead - Working tree is clean, or listed for commit when the
commitstep runs - On the configured branch, and not on a detached HEAD
- The remote exists, is reachable, and the branch is not behind it
- The tag is free — or already exists at
HEAD, in which case it is reused ghis installed and authenticated- Commit and tag signing can actually sign, and the key is one GitHub will accept
- The publishing CLI is authenticated, and the version is not already published
- A crate that
cargo publishwill upload packages cleanly (cargo package) and fits crates.io's 10 MiB upload limit — see below - The previous release actually reached the registry — one that did not is either finished by this run or absorbed into it (warning)
- Configured release assets exist
- With
"requireGreen": true, HEAD is on the remote and GitHub reports it green — see below - The configured
verifycommand passes — the project's own gate (tests, build) runs before anything mutates, instead of aprepublishOnlyhook failing after the commit, tag and push package.json'srepositorymatches the git remote, so the registry's "Repository" link is not broken (warning)- A shallow clone only matters when it truncates the history the release reads: with the
previous tag reachable it passes, without one it fails
auto(the bump would be inferred from partial history) and warns otherwise - A changelog section for the version exists (warning — it falls back to generated notes)
- Notes drafted by an assistant carry no entry it marked
[???]as unsure — refused under--yesor without a terminal, listed for review at the prompt otherwise
Under --dry-run the failures are reported and then the remaining steps are shown anyway,
so you can see the whole plan without fixing the blockers first.
Crates are packaged in preflight
cargo publish packages and verifies the crate as its first act, so a file your
include/exclude left out, metadata crates.io refuses, a crate that does not build from
its own archive, or an archive over crates.io's 10 MiB limit used to surface only after the
release was tagged and pushed. When a cargo publish command is going to run, preflight
runs cargo package with the same arguments first — -p, --workspace,
--manifest-path, --features and --no-verify carry over, so a workspace packages each
crate it will publish — and refuses on a failure or an oversized .crate.
- It packages the tree before the version bump. That is enough because the bump writes
the version into
Cargo.tomlandCargo.locktogether, so the treecargo publishsees differs only in the version number, which changes neither the file list nor the size. ACargo.lockleft on the old version would dirty the tree after the push instead: it is detected beside aCargo.tomlversion source, and a configuredversionFilesthat writesCargo.tomlwithout it is refused while acargo publishis going to run. --lockedis added when the repository has aCargo.lock;--allow-dirtyonly when the tree is dirty (which preflight reports on its own, or thecommitstep is about to fix).- Like
cargo publish, it compiles the crate, so preflight takes a build longer. Put--no-verifyon the publish command if you want neither to compile. - A publish command that is not a plain
cargo publish …— a pipeline, quoting, an environment prefix — is left alone, since what it uploads cannot be read off it. - The 10 MiB is crates.io's default; it can raise the limit for a single crate on request.
Requiring a green HEAD
verify runs your gate locally. "requireGreen": true asks GitHub instead, before anything
is bumped: the release is refused unless the commit being released is on the remote (a
local commit has no checks, so HEAD must not be ahead of origin/<branch>) and its checks
passed. A dirty tree the commit step would fold in is refused too — CI never saw it.
- Failed — any check run concluding
failure,cancelled,timed_out,action_requiredorstale, or a commit status offailureorerror. The failing checks are named. - Not finished — any check run still queued or in progress, or a status still
pending: wait for it and re-run. - Nothing checked — no check run or status reporting
success. Zero checks, or only skipped ones, means nothing verified the commit, which is not the same as green.
Both of GitHub's systems are read — check runs (commits/{sha}/check-runs, what Actions
and GitHub Apps report) and commit statuses (commits/{sha}/status, what external CI such
as Buildkite or Jenkins still posts) — because either one alone can call a commit green
while the other has it red. It needs gh, authenticated, even with the release step off.
Run inside GitHub Actions, the check runs of the current workflow run are ignored: the job
doing the release is itself an unfinished check on that commit. Ordering against other jobs
in the same workflow is what needs: is for.
♻️ Recovering from a failed run
Re-run the same command. Every step is idempotent:
| Already done | What happens |
| ----------------------- | ------------------------------ |
| Version written | No diff to stage, so no commit |
| Tag exists at HEAD | Reused, not recreated |
| Commit and tag pushed | Push is a no-op |
| Version on the registry | Publish skipped |
| GitHub release exists | Release skipped |
So a run that dies at the publish step (2FA timeout, flaky network) picks up exactly where
it stopped. There is no cleanup step, no --resume, and nothing to remember.
auto is included in that. It normally resolves the version from the commits since the
last tag, and after a failed publish there are none — the tag it would read from is the one
the dead run made. Rather than aborting with "no releasable commits", it finishes that
release: same version, same tag, the steps that remain.
A relative bump is the one target that cannot be re-run as-is, because it counts from the
version the dead run already wrote: minor after a dead minor would release 1.2.0
from the commit v1.1.0 already tags. Preflight refuses both shapes of that — the bump
written but never committed, and the tag at HEAD that never reached the registry — and
names the command that finishes the earlier release: release-kit 1.1.0, or no target, or
auto. With "publish": null there is no registry to ask, so a tag at HEAD that the
remote does not have marks the unfinished release instead: a push that was refused is
finished by auto too, and a relative bump over it is refused the same way.
A release that was never published
A tag is not a release. The tag and the push happen before the publish, so a publish that fails leaves the version tagged, pushed and written into the changelog while no registry carries it — and everything that reads "the last release" from tags then reads it wrong.
Once history has moved past that tag, finishing it is no longer possible: publishing sends
what is on disk, and that is no longer what the tag describes. The next release absorbs it
instead. History is read from the last tag whose version actually reached the registry, so
the unpublished release's commits are in range for both the notes and the bump auto
infers — a feature that never shipped still makes the next release a minor. Preflight says
which tags were absorbed, and points at the changelog sections that now document versions
no registry carries.
This costs one registry lookup per release, and the registry is the only thing asked: a
project configured with "publish": null has nothing that can answer, so it reads history
from tags as it always did. When the registry does not answer at all — offline, a proxy, an
expired session, a private package with no credentials — nothing is concluded from the
silence, and history is again read exactly as it was before.
The one case that is not recoverable by re-running is a tag that exists at a different
commit than HEAD. That is a genuine conflict, and it aborts rather than guessing.
🌍 Any language
Only one step is Node-specific: publish. Committing, changelog rolling, tagging, pushing
and GitHub releases are the same everywhere, so versionFile points at wherever a project
keeps its version and the rest works unchanged.
| Project | Config |
| ------------------------------ | -------------------------------------------------------------------------------------- |
| Node (npm) | nothing — package.json and npm publish are the defaults |
| Node (pnpm / bun) | {"publish": "pnpm publish --tag %d"} or {"publish": "bun publish --tag %d"} |
| Rust | nothing — Cargo.toml and cargo publish are detected |
| Rust + npm (a Tauri plugin) | nothing — both manifests bump, both registries publish |
| Python | {"versionFile": "pyproject.toml", "publish": "uv publish"} |
| Go | {"publish": "go list -m %n@%t"} — the tag is the release; a version.go is detected |
| Anything with a VERSION file | {"versionFile": "VERSION", "publish": null} |
| Versioned only by tag | {"versionFile": null}, then release-kit 1.2.3 |
The publish step also gets a preflight when the command is one it recognises:
| Publish command | Authentication | Already published? |
| --------------- | ----------------------------------------------------------- | ----------------------------------- |
| npm / pnpm | whoami | view <name>@<version> |
| bun | bun pm whoami | bun pm view <name>@<version> |
| uv | UV_PUBLISH_TOKEN in the environment | none — uv skips duplicates itself |
| cargo | CARGO_REGISTRY_TOKEN, or cargo login's credentials file | cargo info <crate>@<version> |
| go | none needed | go list -m <module>@<tag> |
Anything else runs as written with no preflight. The project name comes from the manifest —
name in package.json, Cargo.toml or pyproject.toml, module in go.mod — falling
back to the repository directory.
publish is detected too, but only where one ecosystem obviously owns it: package.json
gets npm publish, Cargo.toml gets cargo publish. Python has several publishers (uv,
twine, poetry, flit) and Go has none, so those get nothing rather than a guess — publishing
to the wrong registry is a far worse failure than being asked to configure it. A manifest
that forbids publishing is honoured: a "private": true package.json, a crate with
publish = false, and a crate built only as a cdylib — a native extension module rather
than a library — get no command at all.
One repository, two registries
A Tauri plugin is a crate and its npm bindings built from one source tree; a maturin project is a crate and a wheel. Both manifests sit at the repository root and carry the same version, and both publish on the same release. That needs no config:
tauri-plugin-demo/
package.json @tauri-apps/plugin-demo 1.4.0
Cargo.toml tauri-plugin-demo 1.4.0
Cargo.lockrelease-kit minor --yes
# also versioned in Cargo.toml, Cargo.lock (detected)
# publish: npm publish --tag latest
# publish: cargo publishThe second manifest is detected only when it already carries the same version. Two
manifests on different numbers are two independent release lines, and dragging one to the
other's number is a silent, wrong release — that case says so and leaves the file alone,
pointing at versionFiles for a project that really does want them joined. A project that
wrote its own versionFile or versionFiles is never extended behind its back.
publish therefore takes an array as well as a string, for any project releasing to more
than one registry:
{ "publish": ["npm publish --tag %d", "cargo publish"] }They run in order, each with its own authentication check and its own already-published check, so a re-run after a half-finished publish completes the other half instead of failing. The detected order puts npm before cargo deliberately: npm allows an unpublish for 72 hours and crates.io never does, so a publish that fails part-way through has not already made the permanent half.
Names are resolved per registry rather than once: the crate is looked up under the name
in Cargo.toml, not the npm package name, since @tauri-apps/plugin-demo and
tauri-plugin-demo are the same release under two names.
Languages that have no version of their own
A Go module has only go.mod, and go.mod carries no version — go get resolves a tag.
The same is true of a Swift package or a plain C library. There is nothing to bump, so the
tag is the version and versionFile resolves to null on its own.
Those projects still usually keep a number in source, so mytool --version prints
something:
package main
const Version = "1.2.0"version.go, internal/version/version.go and pkg/version/version.go are detected and
rewritten on release, with no config — const Version, var Version and
var Version string are all read. The tag stays the source of truth; the constant is a
mirror kept in step with it.
Detection adopts a file only when it already carries the current version. That rules out the placeholder a build replaces at link time:
var Version = "dev" // -ldflags "-X main.Version=$(git describe)" — left aloneA mismatch here is skipped silently rather than warned about, because a placeholder is a normal thing to find rather than a mistake — unlike two manifests on different versions.
For a constant somewhere else, or in a language whose convention is not in that list, name
it with a pattern:
{
"versionFiles": [{ "path": "src/version.h", "pattern": "^#define VERSION \"(.+)\"" }]
}versionFiles is written whether or not the project has a versionFile, so this works for
a repository that versions by tag alone.
With no versionFile configured it is detected from the repository — package.json,
pyproject.toml, Cargo.toml, then VERSION — so most projects need no config for it at
all. Set it explicitly to override, or to null for a repository that versions by tag.
The format is inferred from the file name: .json reads the "version" field, .toml
reads the first version = "x.y.z" line, and any other file is treated as containing just
the version. Only the version itself is rewritten, so comments and formatting survive — and
because the TOML match is anchored to the start of a line, a dependency's
serde = { version = "1.0" } is left alone.
Lockfiles are scoped automatically. A Cargo.lock records a version for every
dependency — hundreds of them — so matching the first version = "…" would rewrite an
unrelated crate. Listing one rewrites only the [[package]] blocks this project owns: the
crate named in the sibling Cargo.toml, or — for a workspace root, which has no
[package] of its own — every member that inherits the version with
version.workspace = true. A member pinned to its own number is versioned separately and
is left alone. With nothing beside it to read, it refuses rather than guesses.
package-lock.json, npm-shrinkwrap.json and uv.lock record the project's own version
too, and are refreshed by the tool that owns them rather than rewritten by pattern — the
version sits in more than one place and the formats change shape between tool versions.
Each is scoped to its manifest, so a uv.lock for a component this release is not
versioning stays out of the release commit, and a missing tool warns rather than aborting.
A Cargo.lock beside a detected Cargo.toml is kept in step without being listed, whether
Cargo.toml is the version source (a plain crate) or a companion manifest. A configured
versionFiles is never extended, so list the lockfile there yourself; when a cargo publish
is going to run and a written Cargo.toml would leave its Cargo.lock on the old version,
preflight refuses rather than letting cargo publish fail on the dirty tree after the push.
pnpm-lock.yaml records no root version, so it never goes stale.
Marking the line instead of writing a pattern
The files that most want keeping in step — a README install line, a badge URL, a Dockerfile tag — are the ones where a regex is fiddliest to get right. A comment on the line says which of a file's numbers is the version, so nothing outside the file has to describe where it sits:
Install with `npm i [email protected]` <!-- x-release-kit-version -->
```dockerfile
FROM acme:1.2 # x-release-kit-minor
```-major, -minor, -patch, -date and -version-date write a piece of the release
instead of the whole version — -version-date covers both on one line, which is the shape
of an AppStream <release version="…" date="…"/> tag — and x-release-kit-start-<scope> … x-release-kit-end covers a run of lines
rather than commenting each one. Just list the file:
{ "versionFiles": ["README.md", "Dockerfile"] }A file listed with no marker, no pattern and no recognised extension is treated as
containing nothing but the version — right for a VERSION file, and refused for anything
else rather than replacing its contents with the number.
Paths may contain *, matching within one path segment, so the per-platform configs a
desktop app carries do not have to be written out one by one:
{ "versionFiles": ["src-tauri/tauri.*.conf.json"] }A pattern matching no files aborts: it was written to keep files in step, and silently keeping none of them in step is the failure it was meant to prevent. A file the glob matched that carries no version is skipped, though — a glob says "every file of this shape", and some of them legitimately have none, as a Tauri per-OS overlay does. A file you named on purpose is different: being unable to write it fails preflight, before anything mutates.
For anything else, give a pattern with one capture group around the version. versionFiles
takes the same entries, so several files stay in sync across formats:
{
"versionFile": { "path": "version.go", "pattern": "^const Version = \"(.+)\"" },
"versionFiles": [{ "path": "Chart.yaml", "pattern": "^version: (.+)$" }]
}A desktop app usually carries the same version in a lot of places at once — a workspace manifest, per-platform bundle configs, a crate manifest and its lockfile. They stay in step in one release, across three formats, with no scripting:
{
"versionFiles": [
"apps/desktop/package.json",
"apps/desktop/src-tauri/tauri.*conf.json",
"apps/desktop/src-tauri/Cargo.toml",
"apps/desktop/src-tauri/Cargo.lock"
],
"publish": null,
"steps": ["commit", "version", "changelog", "tag", "push"]
}The glob covers tauri.conf.json and every per-OS overlay beside it, including ones added
later — and the overlays that carry no version of their own are skipped rather than
failing the release.
Stopping at push because the tag is what triggers the build pipeline — see
Libraries versus apps. When release-kit itself runs in CI, the
push must not use GITHUB_TOKEN, or the tag triggers nothing — see
Continuous integration.
The project name comes from the manifest when there is one (name in package.json,
Cargo.toml or pyproject.toml), and falls back to the repository directory.
🚂 Release trains
Interdependent packages — a monorepo, or a plain folder of sibling git repositories — release with the second bin in this package:
release-train --dry-run # plan + whole-train preflight, execute nothing
release-train # the same, then confirm and release (--yes without a terminal)train.mjs derives the dependency graph and publish order from the package manifests
(never from declared config), releases dependencies before dependents with release-kit as
the per-package worker, rewrites internal ranges, and refuses the whole train before
anything mutates if any package would fail. A train.config.json declares only which
directories are members. Design, configuration and the full pipeline are in
TRAIN.md. A member that shares its git repository with other members is
released with --package, so each keeps its own <name>@<version> tags and changelog.
⚙️ Configuration
release.config.json, beside package.json. Every key is optional; unknown keys, and known
keys holding the wrong type (a string where the table says array), abort rather than being
silently ignored.
| Key | Default | Meaning |
| --------------- | ---------------------- | ----------------------------------------------------------------------- |
| steps | all seven | Which steps run; the order is fixed. commit no-ops on a clean tree |
| tagPrefix | "v" | Prepended to the version to form the tag |
| branch | "main" | The only branch a release may run from; null allows any |
| remote | "origin" | Git remote to push to |
| changelog | "CHANGELOG.md" | Changelog path; null for a project without one |
| versionFile | detected | Where the version lives; null versions by tag alone |
| versionFiles | detected | Further files kept in sync; a path or { path, pattern } |
| publish | detected | Publish command, or an array of them; null publishes nothing |
| versioning | "conventional" | How auto infers; or always-patch / -minor / -major |
| verify | null | Command run during preflight; non-zero aborts before anything mutates |
| requireGreen | false | Refuse a HEAD that is not pushed and green on GitHub |
| hooks | {} | Commands run between the steps — see Hooks |
| assistant | null | Drafting CLI: a name, "auto", or { tool, model, effort } |
| commitMessage | "chore(release): %t" | Release commit subject |
| releaseTitle | "%t" | GitHub release title |
| assets | [] | Files attached to the GitHub release |
| notes | "auto" | Notes source; or force changelog / assistant / commits / github |
| notesFile | null | Write the resolved notes here for the tool that runs next |
| hiddenTypes | [] | Commit types left out of commit-derived notes |
| ignoreCommits | release, merge, wip… | Regexes for commits that are bookkeeping rather than change |
Command and message strings expand four tokens: %v version, %t tag, %n package
name, %d npm dist-tag. In the publish command line the substituted values are
shell-quoted, so a version carrying shell metacharacters is passed through as one literal
argument.
Continuous integration
Non-interactive by default: the confirmation prompt is skipped when stdin is not a TTY, and
gh picks up GITHUB_TOKEN on its own. Pass --yes to be explicit.
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
fetch-depth: 0 # release notes and the last-tag lookup need real history
token: ${{ steps.app-token.outputs.token }} # the push below is made with this token
- id: release
run: npx @entro314labs/[email protected] minor --yes
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"On success it writes to $GITHUB_OUTPUT, so later steps can act on what happened instead of
re-deriving it: version, tag, name, dist-tag, steps, published, release-url.
Nothing is written on a dry run, and an unwritable $GITHUB_OUTPUT never fails a release
that already completed.
A tag pushed with GITHUB_TOKEN triggers nothing. GitHub deliberately does not start
workflows from events caused by the job's own GITHUB_TOKEN, so an on: push: tags build
workflow never runs for a tag release-kit pushed with it — the release is tagged and the
build that was supposed to follow it silently does not happen. The push uses whatever token
actions/checkout persisted, which is why the example gives checkout a
GitHub App
installation token (actions/create-github-app-token) — a fine-grained personal access
token with contents: write works the same way. Leave persist-credentials on for this
job: the push has nothing to authenticate with otherwise. The alternative that needs no
second identity is not to rely on the tag event at all: run the build in the same workflow
after the release job (needs: release), or call it as a reusable workflow
(on: workflow_call) with the tag from steps.release.outputs.tag. With GITHUB_TOKEN
alone, the example above still releases correctly; only the tag-triggered follow-up is lost.
Two upstream habits make commit-derived notes trustworthy, and neither is release-kit's job:
Gate the release on CI, and guard against forks. Trigger on
workflow_runafter your check workflow completes — but that event fires for every completed run of it, including pull request runs and failed ones, so the condition has to say which run may release.workflow_runalso checks out the default branch's latest commit, not the commit that was checked, so check outhead_shaexplicitly:on: workflow_run: workflows: [check] types: [completed] branches: [main] jobs: release: # A push to main that passed, in this repository — not a fork's copy of the workflow. if: >- github.repository_owner == 'your-org' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v3 id: app-token with: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - uses: actions/checkout@v7 with: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} # so the tag push triggers workflows # Checking out a SHA leaves a detached HEAD, which release-kit refuses — there is no # branch to push. Put main back at the commit CI checked; if main has moved on # since, preflight refuses as "behind origin/main" instead of releasing it unchecked. - run: git switch -C main - run: npx @entro314labs/[email protected] auto --yes env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}requireGreenis the same gate inside release-kit, for a release run by hand or from a workflow that does not know which checks passed.branches: [main]narrows the trigger to runs onmain, but a pull request from a fork whose branch is namedmainmatches it too;event == 'push'is what rules pull request runs out. The owner check stops a fork's own copy of the workflow from trying to release.Validate pull request titles. A squash-merge takes its subject from the PR title, so that title becomes the commit the notes are built from. Check it with
lint-commits, which uses this tool's own parser rather than a second opinion about the grammar. Without something like it, work silently goes missing from release notes — release-kit says how many commits are not Conventional Commits, but it cannot fix them after the fact.
Three things CI does that are worth knowing about:
fetch-depth: 0. The default checkout is a shallow clone, which hides the history release notes are drafted from. It still releases correctly, but the notes describe a fraction of the work, so a shallow clone is called out as a warning.- Detached HEAD. Tag and pull-request checkouts leave no branch to push, which is a preflight failure rather than a confusing push error.
- Signing. Runners have no signing key, so disable it for the run rather than shipping
keys around:
git -c commit.gpgsign=false -c tag.gpgsign=false.
Signing
Signing is git's, not this tool's: commits and tags are made with plain git commit and
git tag, so they are signed exactly when commit.gpgsign and tag.gpgsign say to, with
whatever key user.signingkey resolves to. There is no key handling here to get wrong.
What it does add is a preflight check, because an unusable key otherwise fails at the commit step with the version already written. For CI, where a signing key usually is not present, disable signing for that run rather than configuring keys:
git -c commit.gpgsign=false -c tag.gpgsign=false release-kit minor --yesFor commits to show as Verified on GitHub, the SSH key must be registered as a signing key in your account, which is a separate list from authentication keys.
Publishing and authentication
The registry preflight (whoami, the already-published lookup) runs with whichever CLI the
publish command names, so a pnpm project is checked with pnpm:
{
"publish": "pnpm publish --tag %d"
}npm and pnpm are both understood. Any other publish command — vsce publish, a shell
pipeline — is run as written with no registry preflight, because there is nothing reliable
to introspect.
Two npm behaviours are handled automatically:
npm loginissues a two-hour session, not a durable token. Classic tokens were permanently revoked in December 2025. A login from earlier in the day has expired, and the preflight failure says so rather than implying you never logged in.- Trusted publishing (OIDC) carries no token
at all. In GitHub Actions with
id-token: write, or GitLab CI/CircleCI withNPM_ID_TOKEN,whoamifails whilepublishsucceeds. That environment is detected and the auth check is skipped, so a valid CI release is not aborted over a missing token it does not need. That covers the CLIs that exchange the OIDC token themselves — npm, pnpm, bun anduv publish. npm only learned to in 11.5.1, and an older one fails the publish after the tag and push, so under OIDC an oldernpmis a preflight failure (npm install -g npm@latestfixes it). pnpm and bun document no minimum and are not checked. cargo is not one of them: crates.io's trusted publishing goes through an action that turns the token intoCARGO_REGISTRY_TOKEN, so the cargo credential check still applies in CI.
Examples
A VS Code extension, published to the marketplace rather than npm:
{
"publish": "vsce publish"
}A browser extension with a separate manifest and a built artifact:
{
"versionFiles": ["src/manifest.json"],
"publish": null,
"assets": ["build.zip"],
"changelog": null
}A project releasing off a non-default branch with a different tag scheme:
{
"branch": "release",
"tagPrefix": "release-",
"commitMessage": "release: %n %v"
}🪝 Hooks
verify covers the gate that matters most — the project's own tests, run during preflight
before anything mutates. What it cannot express is work that has to happen between the
release's own steps.
{
"hooks": {
"afterVersion": "cargo build --release",
"beforePublish": "pnpm build",
"afterRelease": "curl -X POST -d 'shipped %t' https://hooks.example/deploy"
}
}| Hook | Runs |
| --------------- | ------------------------------------------------------- |
| beforeVersion | After preflight, before any file is written |
| afterVersion | After the version is on disk, before the release commit |
| beforePublish | After the tag is pushed, before anything is published |
| afterPublish | After a publish actually ran |
| afterRelease | After the GitHub release is created |
Each takes a command line or an array of them, expanding the same %v %t %n %d
tokens publish does, shell-quoted on substitution. A non-zero exit aborts the release
where it happened. An unknown hook name aborts rather than silently never running.
afterVersion sits where it does for a reason: anything it changes is staged for the
release commit, so a file regenerated from the version rides in that commit instead of
being left behind in the working tree. afterPublish runs only when a publish actually
happened, so a re-run that skipped an already-published target does not tell something
downstream a lie it may act on.
🤖 Assistant (optional)
An assistant is an AI CLI already installed on your machine. When one is configured, release-kit can write the Conventional Commits message for a dirty working tree and draft release notes from the commit log. It is off by default, and every failure — not installed, not authenticated, timed out, unusable answer — falls back to the behaviour you already have. A release is never blocked because a text generator was unavailable.
pnpm release minor --commit --assistant claude --assistant-model sonnet --assistant-effort low| Tool | Invocation | Model | Effort |
| -------- | ------------ | ------------------- | -------------------------------- |
| claude | claude -p | --assistant-model | --assistant-effort (low … max) |
| codex | codex exec | -m | -c model_reasoning_effort= |
Configure it once instead:
{
"assistant": { "tool": "claude", "model": "sonnet", "effort": "low" }
}"assistant": "auto" picks the first tool found on PATH; "claude" is shorthand for
{ "tool": "claude" }. Naming a tool that is not installed is an error rather than a silent
downgrade, so a configured pipeline fails loudly; "auto" degrades quietly by design.
What it does
- Commit messages for a dirty working tree are drafted from the staged diff, instead
of the generated
chore:message used when no assistant is available. The draft is validated, not trusted: a subject that is not Conventional Commits, or a message naming a version the staged changes never touch (a model narrating an unchanged"version"context line), falls back to the generated message rather than being committed. Attribution lines (Co-Authored-By,Generated with) are stripped, so the tool never signs your commits. - Release notes are drafted from the commits since the last tag when
CHANGELOG.mdhas no section for the version. Each bullet ends with a link to the commits it covers: the assistant is given the short hashes and asked to cite them, and every citation is checked against the commits that actually exist. Models invent plausible-looking hashes, so an unrecognised one is removed rather than published as a link to nothing. They are written into the changelog, used as the tag annotation, and posted as the GitHub release body — the same "written once, lands in three places" path a hand-written section takes.
The prompt asks for notes written for someone upgrading: internal chores (CI, formatting,
version and dependency bumps) are left out, except a dependency that ships inside the
product — a bundled runtime, a sidecar binary, an embedded engine — since users run that
code; a fix is described as what works now rather than what was broken; and a commit's
Notes: line is kept in the author's words.
An entry the model is unsure of is flagged, not guessed. The prompt lets it start a
bullet with [???] when it cannot tell whether a change is user-facing or what it means for
someone upgrading. Flagged entries are listed at the confirmation prompt, and answering y
releases them as shown with the marker removed — your answer is the review. With --yes,
or with no terminal to ask on (CI), there is nobody to review them, so preflight refuses
before anything mutates: re-run in a terminal, or write the notes into [Unreleased] with
the entries resolved, since a hand-written section wins over a draft. When a dirty tree
defers drafting past the prompt, the check runs right after the working-tree commit instead
and asks again (or, non-interactively, stops there with only that commit made). This applies
to drafted notes only — commit-derived and hand-written notes are never checked for the
marker.
With --commit, notes are drafted after that commit lands, so they describe the change it
just made. Merge, release, WIP and fixup!/squash! commits are excluded from the prompt.
Adding another tool
One row in ASSISTANTS in release.mjs: the command, the args that make it read a prompt on
stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
declare outputFile and the answer is read from there instead.
📚 Libraries versus apps
Publishing splits in two, and which one you are decides the steps you want.
A library, package or tool publishes its source. The registry receives what is already
in the repository — npm publish, cargo publish, uv publish — and there is nothing to
build first. release-kit does the whole thing:
{}Defaults are already correct: version → changelog → tag → push → publish → release.
An app has to be built before anything can be published. Binaries, installers, bundles, container images: the artifact does not exist until something makes it. That build belongs to a build tool, and it changes where release-kit stops.
Apps with a simple build
If the build runs before the release and leaves files on disk, release-kit can attach them itself. Nothing else is needed:
{ "assets": ["dist/app-macos.zip", "dist/app-linux.tar.gz"], "publish": null }Preflight fails if a listed asset is missing, so a release cannot quietly ship without its binaries.
Apps with a real build pipeline
goreleaser, cargo-dist and electron-builder build for many targets and create the GitHub release themselves, with the artifacts attached. That is their job. release-kit's work ends at the pushed tag:
{ "steps": ["commit", "version", "changelog", "tag", "push"] }Nothing after push — no publish, no release. The tag push is the handoff, and it is
what triggers the build workflow — unless it was pushed with GITHUB_TOKEN from another
workflow, which trigg
