npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

🚀 release-kit

Single-file, zero-dependency release automation for JS/TS/Node projects.

version bump → changelog → commit → annotated tag → push → publish → GitHub release

npm downloads unpacked size dependencies node license

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.0

Contents

| | | | ----------------------------------------------------------------------- | --------------------------------------- | | 📦 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 minor

CI, 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 --yes

Vendored — 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 --yes

Pin 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-kit is 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 -- --help

next 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 uses

The 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 title

It 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 work

Or 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:

  1. The CHANGELOG.md section 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.
  2. 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.
  3. 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 as security:, lands under Other Changes. Each bullet links to its commit, and closes #12 / fixes #34 in a message becomes a link to the issue. A BREAKING 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.
  4. 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 progress

Which 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 publish sends 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 at HEAD that 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 commit step 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
  • gh is 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 publish will 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 verify command passes — the project's own gate (tests, build) runs before anything mutates, instead of a prepublishOnly hook failing after the commit, tag and push
  • package.json's repository matches 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 --yes or 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.toml and Cargo.lock together, so the tree cargo publish sees differs only in the version number, which changes neither the file list nor the size. A Cargo.lock left on the old version would dirty the tree after the push instead: it is detected beside a Cargo.toml version source, and a configured versionFiles that writes Cargo.toml without it is refused while a cargo publish is going to run.
  • --locked is added when the repository has a Cargo.lock; --allow-dirty only when the tree is dirty (which preflight reports on its own, or the commit step is about to fix).
  • Like cargo publish, it compiles the crate, so preflight takes a build longer. Put --no-verify on 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_required or stale, or a commit status of failure or error. 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.lock
release-kit minor --yes
#   also versioned in Cargo.toml, Cargo.lock (detected)
#   publish: npm publish --tag latest
#   publish: cargo publish

The 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 alone

A 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_run after 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_run also checks out the default branch's latest commit, not the commit that was checked, so check out head_sha explicitly:

    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 }}

    requireGreen is 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 on main, but a pull request from a fork whose branch is named main matches 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 --yes

For 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 login issues 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 with NPM_ID_TOKEN, whoami fails while publish succeeds. 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 and uv 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 older npm is a preflight failure (npm install -g npm@latest fixes 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 into CARGO_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.md has 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