@williamthorsen/release-kit
v5.3.1
Published
Version-bumping and changelog-generation toolkit for release workflows
Readme
@williamthorsen/release-kit
Version-bumping and changelog-generation toolkit for release workflows.
Provides a self-contained CLI that auto-discovers workspaces from pnpm-workspace.yaml, parses conventional commits, determines version bumps, updates package.json files, and generates changelogs from git-cliff --context output rendered in-process (with optional editorial overrides).
Release notes — v5.3.1 (2026-05-19)
🐛 Bug fixes
Validate overrides against the full release history (#401)
Fixes an issue where
release-kit overrides validatereported overrides as stale when they targeted commits in past releases, even thoughrelease-kit preparecorrectly matched them. The two commands now agree on which overrides are stale.
Installation
pnpm add -D @williamthorsen/release-kitQuick start
# 1. Set up release-kit in your repo (scaffolds the release workflow)
npx @williamthorsen/release-kit init
# 2. Preview what a release would do
npx @williamthorsen/release-kit prepare --dry-runExample output from prepare --dry-run in a monorepo:
🔍 DRY RUN — no files will be modified
── arrays ──────────────────────────────────────
Found 4 commits since arrays-v1.2.0
Parsed 3 typed commits
Bumping versions (minor)...
📦 1.2.0 → 1.3.0 (minor)
[dry-run] Would bump packages/arrays/package.json
Generating changelogs...
[dry-run] Would write packages/arrays/CHANGELOG.md
🏷️ arrays-v1.3.0
── strings ─────────────────────────────────────
Found 2 commits since strings-v0.5.1
Parsed 2 typed commits
Bumping versions (patch)...
📦 0.5.1 → 0.5.2 (patch)
[dry-run] Would bump packages/strings/package.json
Generating changelogs...
[dry-run] Would write packages/strings/CHANGELOG.md
🏷️ strings-v0.5.2
✅ Release preparation complete.
🏷️ arrays-v1.3.0
🏷️ strings-v0.5.2That's it for most repos. The CLI auto-discovers workspaces and applies sensible defaults. The bundled cliff.toml.template is used automatically — no need to copy it. Customize only what you need via .config/release-kit.config.ts.
How it works
- Workspace discovery: reads
pnpm-workspace.yamland resolves itspackagesglobs to find workspace directories. Each directory containing apackage.jsonbecomes a workspace. If no workspace file is found, the repo is treated as a single-package project. - Config loading: loads
.config/release-kit.config.ts(if present) via jiti and merges it with discovered defaults. - Commit analysis: for each workspace, finds commits since the last version tag, parses them for type and scope, and determines the appropriate version bump.
- Version bump + changelog: bumps
package.jsonversions, builds structuredChangelogEntry[]fromgit-cliff --context, applies any editorial overrides from per-scope.meta/changelog-overrides.jsonfiles, and renders bothCHANGELOG.mdand.meta/changelog.jsonfrom that single source.git-cliffis invoked only for its--contextJSON; markdown rendering happens in-process so.meta/changelog.jsonandCHANGELOG.mdalways agree. - Release tags file: writes computed tags to
tmp/.release-tagsfor the release workflow to read when tagging and pushing.
Commit format
release-kit parses commits in these formats:
type: description # e.g., feat: add utility
scope|type: description # e.g., arrays|feat: add compact function
type(scope): description # e.g., feat(arrays): add compact function
type!: description # breaking change (triggers major bump)
scope|type!: description # scoped breaking change
type(scope)!: description # conventional scoped breaking changeThe scope|type: format scopes a commit to a specific workspace in a monorepo. Use scopeAliases in your config to map shorthand names to canonical scope names.
Configuration
Configuration is optional. The CLI works out of the box by auto-discovering workspaces and applying defaults. Create .config/release-kit.config.ts only when you need to customize behavior.
Config file
import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
const config: ReleaseKitConfig = {
// Exclude a workspace from release processing
workspaces: [{ dir: 'internal-tools', shouldExclude: true }],
// Run a formatter after changelog generation (modified file paths are appended as arguments)
formatCommand: 'npx prettier --write',
// Override the default version patterns
versionPatterns: { major: ['!'], minor: ['feat', 'feature'] },
// Add or override work types (merged with defaults by key)
workTypes: { perf: { header: 'Performance' } },
};
export default config;The config file supports both export default config and export const config = { ... }.
ReleaseKitConfig reference
| Field | Type | Description |
| ------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| cliffConfigPath | string | Explicit path to cliff config. If omitted, resolved automatically: .config/git-cliff.toml → cliff.toml → bundled template |
| workspaces | WorkspaceOverride[] | Override or exclude discovered workspaces (matched by dir) |
| formatCommand | string | Shell command to run after changelog generation; modified file paths are appended as arguments |
| versionPatterns | VersionPatterns | Rules for which commit types trigger major/minor bumps |
| scopeAliases | Record<string, string> | Maps shorthand scope names to canonical names in commits |
| workTypes | Record<string, WorkTypeConfig> | Work type definitions, merged with defaults by key |
| breakingPolicies | Record<string, 'forbidden' \| 'optional' \| 'required'> | Per-type !-policy lookup. Defaults to DEFAULT_BREAKING_POLICIES. Replaces the default entirely when provided. Set to {} to disable enforcement |
| retiredPackages | RetiredPackage[] | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
| project | ProjectConfig | Opt-in project-level release block. Declaring project: {} (even empty) enables a project-release stage in prepare |
All fields are optional.
WorkspaceOverride
interface WorkspaceOverride {
dir: string; // Package directory name (e.g., 'arrays')
shouldExclude?: boolean; // If true, exclude from release processing
legacyIdentities?: LegacyIdentity[]; // Prior `(name, tagPrefix)` identities for this workspace
}
interface LegacyIdentity {
name: string; // Full scoped npm name at the time (e.g., '@scope/pkg')
tagPrefix: string; // Tag prefix under which historical tags were published (e.g., 'core-v')
}legacyIdentities captures prior identities of a workspace as complete (name, tagPrefix) snapshots. The union of the current tagPrefix and each identity's tagPrefix is consulted when release-kit searches for the most recent baseline tag and when generating changelogs. Use it when a workspace's historical tags were published under a different npm name, a different tag prefix, or both — typically across a package rename. Both fields are required per identity: each entry must be a complete historical snapshot that stays valid regardless of subsequent renames. Run release-kit show-tag-prefixes to detect undeclared candidates and produce a paste-ready config snippet. Listing the current identity (full (name, tagPrefix) match) is rejected as a no-op duplicate; an identity whose tagPrefix matches the current but whose name differs is valid and documents a prior rename that reused the same tag shape. If the workspace no longer exists in this repo at all (the package was extracted or removed), use retiredPackages instead.
RetiredPackage
interface RetiredPackage {
name: string; // Final scoped npm name while the package lived in this repo
tagPrefix: string; // Tag prefix under which the package's historical tags were published
successor?: string; // Optional successor package name (e.g., 'readyup')
}retiredPackages is the repo-level complement to legacyIdentities. Use legacyIdentities when the workspace still exists in this repo under a new identity; use retiredPackages when no workspace for this package exists in this repo anymore — the package was extracted to another repo or removed outright. Retired entries are inert: release-kit never consults them for baseline lookup or changelog attribution. Their declared tagPrefix values are recognized as historical, so show-tag-prefixes stops flagging them under "Undeclared tag prefixes."
Worked example — preflight was extracted from this monorepo and continues as the standalone readyup project. Its tags stay in this repo as historical anchors:
import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
const config: ReleaseKitConfig = {
retiredPackages: [{ name: '@scope/preflight', tagPrefix: 'preflight-v', successor: 'readyup' }],
};
export default config;Validation rules:
nameandtagPrefixare required per entry and must be non-empty strings.successoris optional; if present, it must be a non-empty string.- Full-tuple
(name, tagPrefix)duplicates withinretiredPackagesare rejected. - Two entries sharing the same
tagPrefixbut differentnames are accepted — this documents a package renamed within the repo before being retired.
show-tag-prefixes currently does not render a dedicated "Retired packages" section (deferred). Declaring a retired entry is verifiable by confirming that its tagPrefix stops appearing under "Undeclared tag prefixes" in the show-tag-prefixes output.
Tag prefix collisions
Tag prefixes from distinct owners must not be identical or be a strict prefix of one another. An owner is one of:
- An active workspace, comprising its derived
tagPrefixplus any declaredlegacyIdentities[].tagPrefix. Identities of the same workspace are one owner, so their prefixes are allowed to overlap (this represents the same package across renames). - A
retiredPackages[]entry (one owner per entry). - The
projectblock, when configured.
release-kit resolves baseline tags via git describe --match=<prefix>*, so a strict-prefix overlap between distinct owners would cause that glob to return cross-matches against the wrong owner's history. For example, a project prefix of v collides with a workspace prefix of vue-helpers-v, since git describe --match=v* would return both project tags and vue-helpers tags.
The rule is enforced at config load; the resulting error identifies both colliding declarations.
Project releases
Some monorepos ship a single combined deliverable — a Chrome extension, a CLI binary, a packaged desktop app — for which the per-workspace tags and changelogs alone do not describe what the user actually receives. Declare the optional project block to add a project-level release stage that runs alongside the per-workspace pipeline.
import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
const config: ReleaseKitConfig = {
// Empty object is enough to opt in. Every non-excluded workspace contributes.
project: {},
};
export default config;When configured, each release-kit prepare run additionally:
- Computes commits since the last project tag (
<tagPrefix><version>), filtered to the union of every contributing workspace's paths. - Bumps the root
package.json'sversionfield using the same bump-derivation rules as workspaces (or the--bump=...override). - Regenerates the root
./CHANGELOG.mdfrom the structuredChangelogEntry[]produced bygit-cliff --context(scoped to the project'stagPrefixand contributing paths) and any matching editorial overrides. - Emits
./.meta/changelog.json(whenchangelogJson.enabled). - With
--with-release-notes, additionally emits./docs/RELEASE_NOTES.v<version>.md. - Appends the project tag to
tmp/.release-tagssorelease-kit commitandrelease-kit tagpick it up alongside per-workspace tags.
If no contributing workspace has commits since the last project tag, the project release is silently skipped — same behavior as a per-workspace skip.
ProjectConfig
interface ProjectConfig {
tagPrefix?: string; // Defaults to 'v'
}| Field | Default | Description |
| ----------- | ------- | -------------------------------------------------------------------- |
| tagPrefix | 'v' | Prefix for project tags. The full tag is ${tagPrefix}${newVersion} |
Contributing workspaces are implicit: every non-excluded discovered workspace contributes. There is no field to override the contributing set in this initial release; if a future consumer needs to release a workspace as a component but exclude it from the project release, that override can be added then.
Validation rules:
- The root
package.jsonmust exist and declare aversionfield. release-kit reports an error at config-load time if either is missing. - The
projectblock is rejected in single-package mode (the implicit "all non-excluded workspaces contribute" rule is meaningless in a single-package repo). - Unknown fields inside
projectare rejected.
CLI flag interactions:
--dry-runpreviews project artifacts alongside workspace artifacts; no files are written.--bump=major|minor|patchpropagates to the project release as a level chooser. It does not trigger a release on its own when there are no commits or no bump-worthy commits.--forceruns the project release even when no commits or no bump-worthy commits exist since the last project tag. Defaults to patch when--bumpis not given; combine with--bump=Xto release at a different level.--onlyis rejected with an error whenprojectis configured.--onlyis a surgical, single-workspace operation; combining it with a project release that rolls up every contributing workspace would create ambiguous semantics. To release a single workspace, use a config without aprojectblock, or run a fullprepare(no--only) to include the project release.--set-versionis rejected with an error whenprojectis configured.--set-versionoperates on a single workspace, but a project release rolls up every contributing workspace; the two semantics don't compose. To use--set-version, run on a config without aprojectblock.
--bump and --force are orthogonal: --bump is purely a level chooser; --force is purely a release trigger. Examples:
# Release every target at its natural bump level (no flags).
release-kit prepare
# Force a release even when no bump-worthy commits exist; defaults to patch
# per target, with each target keeping its natural bump if one is derivable.
release-kit prepare --force
# Force a release at a uniform level across every releasing target.
release-kit prepare --force --bump=minor
# --bump=X alone is a level chooser, NOT a trigger. If a target has no
# bump-worthy commits, it skips with a "Pass --force..." reason. If it has
# bump-worthy commits, the override applies. (Behavioral change from earlier
# release-kit versions, where --bump=X alone would force a release.)
release-kit prepare --bump=minorVersionPatterns
Defines which commit types trigger major or minor bumps. Any recognized type not listed defaults to a patch bump.
interface VersionPatterns {
major: string[]; // Patterns triggering a major bump ('!' = any breaking change)
minor: string[]; // Commit types triggering a minor bump
}Default: { major: ['!'], minor: ['feat'] }
Work types and tiers
The canonical taxonomy lives in packages/release-kit/src/work-types.json and is split into three tiers that drive section rendering and audience classification.
| Tier | Key | Header | Aliases | ! policy |
| -------- | ----------- | ------------------------- | ------------- | ------------ |
| public | feat | 🎉 Features | feature | optional |
| public | drop | 🪦 Removed | | required |
| public | deprecate | 🗑️ Deprecated | | forbidden |
| public | fix | 🐛 Bug fixes | bugfix | forbidden |
| public | sec | 🔒 Security | security | optional |
| public | perf | ⚡ Performance | performance | forbidden |
| internal | internal | 🏗️ Internal features | utility | forbidden |
| internal | refactor | ♻️ Refactoring | | forbidden |
| internal | tests | 🧪 Tests | test | forbidden |
| process | tooling | ⚙️ Tooling | | forbidden |
| process | ci | 👷 CI | | forbidden |
| process | deps | 📦 Dependencies | dep | forbidden |
| process | ai | 🤖 Agentic support | | forbidden |
| process | docs | 📚 Documentation | doc | forbidden |
| process | fmt | (excluded from changelog) | | forbidden |
Tier semantics
public— visible to all audiences.public-tier sections appear in both public release notes and dev changelogs.internal— dev-only.internal-tier sections appear in dev changelogs but not in public-facing release notes.process— dev-only. Same audience treatment asinternal.
Section render order is tier order (public → internal → process), then row order within tier. The bundled cliff.toml.template encodes this order via hidden <!-- NN --> HTML-comment prefixes on each parser's group value; tera's group_by filter sorts groups lexicographically (now monotonic by row number), and the body template's striptags filter erases the prefix from rendered headings.
docs reclassification
docs/Documentation has moved from the all-audience tier (where it lived before this taxonomy was formalised) to the dev-only process tier. Documentation commits no longer appear in public-facing release notes. They still appear in CHANGELOG.md and changelog.json under the audience: 'dev' classification.
utility alias
utility: is a backward-compat alias for internal:. Both forms parse to the same canonical type, route to the same 🏗️ Internal features section, and are subject to the same ! policy.
! (breaking change) policy
Each work-type carries a breakingPolicy value:
optional(feat,sec) —!is allowed; bothtype:andtype!:parse cleanly.forbidden(most types) —!is a policy violation. The premise: types likeinternal!,perf!,fix!are contradictory; an internal change cannot break a consumer contract, a pure perf change preserves the contract, and a bug-fix is by definition not a contract change.required(drop) — baredrop:is a policy violation; onlydrop!:is accepted. The premise: removing a feature always breaks consumers; the!form makes that explicit.
Two-tier policy enforcement
The ! policy operates at two distinct levels with different semantics:
- Write-time (commit-msg hook) — strict rejection. Policy violations are blocked at the gate where the author can act on them immediately. Hook-based enforcement is tracked separately and is not yet shipped.
- Release-time (
parseCommitMessage) — tolerant warn-and-continue. Commits already in the log cannot be rewritten, so a policy-violating commit is parsed using its canonical type withbreaking: false(the!is dropped from the parse) and aonPolicyViolationcallback fires. Callers (decideReleaseetc.) can collect these warnings and surface them in the release report. A single legacyinternal!in a year-old log does not block releases.
A BREAKING CHANGE: body footer on a forbidden-policy type triggers the same warning path as the prefix ! does — the spirit of the policy is "internal/perf/etc. cannot be breaking", which must apply to both surfaces.
The release-prepare orchestrators (releasePrepare, releasePrepareMono, releasePrepareProject) apply DEFAULT_BREAKING_POLICIES automatically. Violations encountered while parsing each workspace's or project's commit window are collected onto the corresponding result's policyViolations field and rendered under the section in the prepare report:
arrays
Found 1 commits since arrays-v1.0.0
⚠️ 1 policy violation:
· def5678 'internal!: refactor cache' — type 'internal' at prefix surface
Bumping versions (patch)...
📦 1.0.0 → 1.0.1 (patch)To customize, set breakingPolicies in release-kit.config.ts — provide a partial map to override individual types, or {} to disable enforcement entirely (the parser falls back to 'optional' for any missing type). Violations remain warnings, never failures.
🚨 **Breaking:** bullet marker
Items whose commit subject carries the ! prefix (e.g. feat!, drop!, feat(api)!) are rendered with a 🚨 **Breaking:** prefix on the bullet:
- 🚨 **Breaking:** Drop legacy /v1 endpointOnly the prefix ! triggers this marker. A BREAKING CHANGE: body footer on its own does not retroactively mark a changelog item as breaking — the changelog signal is tied to the commit-prefix policy. This avoids surprise breaking-marker appearances for older commits written under earlier conventions.
The emoji and label of this marker are sourced from the markers.breaking entry in work-types.json (see Section markers) so consumers that render their own breaking-changes section draw from the same SSOT.
Section markers
Alongside tiers and types, work-types.json exposes a top-level markers object for cross-cutting section markers — visual indicators that aren't tied to a specific work type. Today the canonical entry is breaking; additional keys (e.g., security advisories, migration notices) can be added without a schema change.
{
"markers": {
"breaking": { "emoji": "🚨", "label": "Breaking" },
},
}Entries store plain text only — the SSOT is format-agnostic, so consumers apply their own emphasis (Markdown bold, ANSI escape, HTML <strong>) when constructing the rendered form. release-kit's own renderer constructs the per-bullet prefix as ${emoji} **${label}:** from this entry.
fmt
fmt: commits are recognized by parseCommitMessage (they contribute to a patch bump) but fmt carries excludedFromChangelog: true. The bundled cliff.toml.template skips fmt: commits at the parser level, so they never appear in CHANGELOG.md, changelog.json, or release notes. The label and emoji are present in work-types.json for schema parity with the codeassembly upstream but never render.
Custom work types
Work types from your config are merged with these defaults by key — your entries override or extend, they don't replace the full set. Release-notes sections are rendered in the declaration order of the merged work-types record, with any unknown titles trailing the known ones.
The default devOnlySections (excluded from public release notes but still written to CHANGELOG.md) are derived from the internal and process tiers (excluding fmt). Override via changelogJson.devOnlySections in your config; matching is decorator-tolerant, so a bare-name override like ['Internal features'] keeps working against the emoji-prefixed and prefix-decorated default titles.
Editorial overrides
Generated changelogs occasionally need editorial correction — typos, redacted scope, reworded entries, or historical commits whose bodies carry verbatim PR-template scaffolding (## What, ## Why, etc.) that renders as literal text in user-facing release notes. Rewriting git history is not viable, and any in-place edit to CHANGELOG.md or .meta/changelog.json is overwritten on the next release because release-kit regenerates both artifacts from scratch.
Override files are the supported escape hatch. Drop a checked-in JSON file at the conventional path for the scope you want to influence, keyed by commit hash, and release-kit prepare applies the overrides between buildChangelogEntries and serialization. Both CHANGELOG.md and .meta/changelog.json reflect the post-override view, so downstream consumers (the GitHub Release body, the in-app release-notes page, etc.) see the same content.
File-location convention
| Scope | Path | Applies to |
| ------------------- | ---------------------------------------------- | ------------------------------------------------------- |
| Project (root) | .meta/changelog-overrides.json | The project changelog and every workspace's changelog |
| Workspace | packages/<ws>/.meta/changelog-overrides.json | Only that workspace's changelog |
| Single-package mode | .meta/changelog-overrides.json | The package's changelog (collapses to the project case) |
Filenames have no leading dot — the .meta/ directory already provides the visibility property and parallels its sibling artifacts (changelog.json, label-map.json).
Composition: per-key shadowing
When a workspace's changelog is rendered, both files are consulted:
- The root file's overrides apply globally.
- The workspace file's overrides apply only to that workspace.
- When the same hash key (string-equal, byte-for-byte) appears in both files, the workspace entry wins entirely for that workspace's changelog — no field-level merge, the workspace entry replaces the root entry.
- Different prefix strings that happen to resolve to the same commit do not shadow; they fall through to the existing ambiguous-prefix error so you can correct your override file.
- Other keys in the root file still apply for that workspace.
The project-level changelog applies only the root file. Per-workspace files describe per-workspace editorial intent and have no meaning at the aggregated project tier.
Stale-key warnings
A key that doesn't match any commit gets a stale-reference warning. The warning's scope mirrors the file's scope:
- Per-workspace files are warned against their own apply context. A key in
packages/foo/.meta/changelog-overrides.jsonthat doesn't match any commit in foo's changelog is unambiguously stale and is warned immediately. - Root file keys are aggregated globally — a root key that matches in any workspace or in the project changelog is non-stale; a root key matched nowhere is warned exactly once after all batches complete.
File shape
{
"82962311": {
"audience": "skip"
},
"abc1234d": {
"body": "Cleaned-up prose without the original PR-template scaffolding."
},
"ef567890": {
"description": "Rewritten headline that fixes the typo",
"body": "Optional replacement body."
}
}Per-entry fields are all optional, but at least one must be present per entry:
| Field | Type | Effect |
| ------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| audience | 'all' \| 'dev' \| 'skip' | 'skip' removes the entry entirely. 'all' and 'dev' are reserved for a future audience-reclassification feature (see below). |
| description | string | Replaces the entry's bullet headline. Other fields are preserved. |
| body | string | Replaces the entry's body (the prose that renders below the bullet). Other fields are preserved. |
| breaking | boolean | Toggles the 🚨 **Breaking:** marker on the bullet. |
Hash-prefix matching
Keys can be either the full 40-character commit SHA or a non-ambiguous prefix. The matcher walks every ChangelogItem.hash value present in the entry tree and resolves each override key to its set of matching hashes:
- Exact prefix match (1 hit) — the override applies. A 7-character prefix is usually unambiguous within a single repo's history; longer prefixes are always safe.
- No matches (0 hits) — the override is treated as a stale reference (probably from a rebase or branch deletion) and a warning is logged. The release continues.
- Ambiguous prefix (2+ hits) — the release aborts with an error naming the key and the matching hashes. Lengthen the prefix or use the full SHA.
Override application errors abort the run with a non-zero exit; warnings (zero-match keys) are non-fatal and surface on PrepareResult.warnings.
Validation
The override file is validated when release-kit prepare loads it. Each error names the offending key so you can locate it in your file:
- Missing file → empty map, no error (the no-op default — projects that do not need overrides skip the file entirely).
- Malformed JSON → error.
- Wrong top-level shape (e.g., array, primitive) → error.
- Unknown fields on an entry → error.
- Wrong field types (e.g.,
descriptionas a number) → error. - An entry with no fields set → error (a copy-paste mistake more often than not).
audience: 'all'oraudience: 'dev'→ error in the current release: only'skip'is supported (see below).
Standalone validation: release-kit overrides validate
For a focused overrides-only health check (locally or as a CI gate), run:
pnpm exec release-kit overrides validateThis walks every .meta/changelog-overrides.json file across the project tier and per-workspace tier, reporting three classes of finding:
| Class | Examples | Exit code |
| ------------------- | ------------------------------------------------------------------------------------------- | --------- |
| Schema/parse errors | malformed JSON, unknown fields, wrong field types, no-field entries, unsupported audience | 2 |
| Ambiguous-prefix | an override key resolves to 2+ commit hashes | 2 |
| Stale-key warnings | an override key resolves to no commit in its applicable scope | 1 |
Exit code semantics:
0— clean (no errors, no stale keys).1— only stale-key warnings.2— schema/parse or ambiguous-prefix errors (errors dominate when both classes are present).
Tier-aware stale-key semantics match release-kit prepare's match-set exactly: a workspace-tier key is stale if it does not match in its own workspace's history; a root-tier key is stale only if it matches in no scope (no workspace AND not the project release window).
The same logic is also exposed programmatically via the validateAllChangelogOverrides function exported from @williamthorsen/release-kit, for callers that want to integrate the check into their own tooling.
Audience semantics: v1 supports 'skip' only
The on-disk format declares the full 'all' | 'dev' | 'skip' audience vocabulary so the file format will not need to change when the v2 reclassification feature ships. In the current release, only 'skip' is supported at runtime; 'all' and 'dev' are rejected with an explicit "not yet supported" error.
The eventual v2 behavior will let an override move a single item to a different audience section (e.g., reclassifying a Documentation entry as Internal features to keep it out of public-facing release notes). v1 deliberately leaves that as a separate change so the override mechanism can ship now and the section-split logic can land additively later.
Worked example 1: cleaning up scaffolded historical commits (root file)
Suppose a year-old commit 82962311 was authored from a PR template that left ## What / ## Why headings in the body, and that commit now appears in your in-app release notes as literal Markdown headings. Add an override at the project tier:
// .meta/changelog-overrides.json
{
"82962311": {
"body": "Add the in-app release-notes page with version-aware navigation."
}
}On the next release-kit prepare run, the matched item's body is replaced before the JSON and Markdown artifacts are written. The original git history is untouched.
Worked example 2: suppressing a cross-attribution spillover (workspace file)
Release-kit attributes commits to workspaces by file path, so a commit that primarily belongs to one workspace can land in another's changelog if it touched files there. Suppose commit 1ce3d2f renamed the audit-deps package to v11y-check (scope v11y-check) but also edited packages/nmr/src/default-scripts.ts and packages/nmr/README.md. The commit correctly appears in packages/v11y-check/CHANGELOG.md, but it also spills into packages/nmr/CHANGELOG.md where it isn't the right editorial framing.
Drop a workspace-tier override at packages/nmr/.meta/changelog-overrides.json:
// packages/nmr/.meta/changelog-overrides.json
{
"1ce3d2f": {
"audience": "skip"
}
}The commit is now suppressed in nmr's changelog only — it still appears in v11y-check's, where it belongs. A root-tier 'skip' would have removed it from both, which is the wrong outcome.
Rendering pipeline change
Prior versions of release-kit shelled out to git-cliff for both structured --context JSON and rendered Markdown (cliff's body template). After this change, git-cliff is invoked only for --context JSON; release-kit's in-process renderChangelogMarkdown produces CHANGELOG.md from the same ChangelogEntry[] that drives .meta/changelog.json. The two artifacts can no longer disagree.
The bundled cliff.toml.template's body template has been emptied (the [git].commit_parsers section is still load-bearing for --context group assignment); custom .config/git-cliff.toml files no longer need a body template.
Observable output differences from the prior cliff-rendered format:
- Trailers (
Signed-off-by:,Co-authored-by:,Closes #N, GitHub PR URLs) are stripped from rendered bodies. - Items whose commit subject carries the
!breaking marker render with a🚨 **Breaking:**prefix. - Empty version entries (releases with no commits routed to a section) are omitted; the prior cliff template rendered them as bare headings.
- The footer comment is now
<!-- Generated by release-kit. Do not edit this file. Use .meta/changelog-overrides.json to override entries. -->.
The first release that ships under the new renderer will produce a one-time noisy diff in CHANGELOG.md (whitespace, trailers, breaking markers). Subsequent releases stabilize.
CLI reference
Global options
| Flag | Description |
| ----------------- | ------------------- |
| --help, -h | Show help message |
| --version, -V | Show version number |
release-kit prepare
Run release preparation with automatic workspace discovery.
| Flag | Description |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| --dry-run | Preview changes without writing files |
| --bump=major\|minor\|patch | Override the bump type for all workspaces |
| --set-version=X.Y.Z | Set an explicit canonical semver version; bypasses commit-derived bumps. Requires --only in monorepo mode. |
| --force | Release even when no commits or no bump-worthy commits exist since the last tag (defaults to patch; combine with --bump=X for a different level) |
| --only=name1,name2 | Only process the named workspaces (monorepo only; rejected when a project block is configured) |
| --with-release-notes | Write per-workspace release-notes previews under {workspacePath}/docs/ |
| --help, -h | Show help |
Workspace names for --only match the package directory name (e.g., arrays, release-kit).
Previewing release notes with --with-release-notes
--with-release-notes writes two versioned files per workspace after each workspace's changelog.json is produced:
{workspacePath}/docs/README.v{version}.md— the workspaceREADME.mdwith release notes injected at the<!-- section:release-notes -->marker.{workspacePath}/docs/RELEASE_NOTES.v{version}.md— the standalone release notes for this version.
The publish-time inject-and-revert lifecycle is unchanged; previews are additive, deterministic, and safe to regenerate. When changelogJson.enabled is false, the flag logs a warning and skips preview generation. In dry-run mode, planned writes are logged and no files are created.
Because preview filenames are versioned, committing them will accumulate files over time. The recommended .gitignore entry for monorepos is:
packages/*/docs/*.v*.mdFor single-package repos:
docs/*.v*.mdSetting an explicit version with --set-version
The --set-version flag is a first-class escape hatch for the cases where commit-derived bump logic produces the wrong version — most notably, promoting a pre-1.0 package to 1.0.0. Pre-1.0 packages collapse a feat! breaking change to a minor bump (matching semantic-release's initialMajor: false and release-please's bump-minor-pre-major), so a deliberate promotion to 1.0.0 must be requested explicitly.
The flag validates that:
- The value is canonical
N.N.Nsemver (pre-release suffixes are rejected). - The target is strictly greater than the current version (numeric comparison on each component).
- In monorepo mode,
--onlyis set and resolves to exactly one workspace.
--set-version is mutually exclusive with --bump and --force. The rest of the pipeline (changelog generation, tag creation, commit summary, propagation to dependents) runs unchanged, so dependents receive a propagated patch bump triggered by the overridden version.
Promoting a pre-1.0 package to 1.0.0 in a monorepo:
release-kit prepare --only arrays --set-version 1.0.0An empty changelog section is expected for a bare promotion, because the changelog is generated from commits since the last tag. To include a narrative entry, land a descriptive release commit (e.g., a feat! describing the stable API) before running prepare.
release-kit publish
Publish packages that have release tags on HEAD. The publish workflow's reusable workflow publish.reusable.yaml invokes this command in CI.
| Flag | Description |
| ---------------------- | ---------------------------------------------------------------------------- |
| --dry-run | Preview without publishing |
| --no-git-checks | Skip the clean-working-tree check |
| --tags=tag1,tag2,... | Only publish the named tags (comma-separated, full tag names) |
| --provenance | Generate provenance statement (requires OIDC, not supported by classic yarn) |
| --help, -h | Show help |
Publishability filter
publish operates only on workspaces where package.json#private is absent or false. A workspace marked private: true is "versioned but not published": it can still be tagged by release-kit tag, get a CHANGELOG.md entry, and get a GitHub Release via release-kit create-github-release — only the registry publish step is skipped. Other commands ignore this filter and operate on private workspaces unchanged.
The filter behaves differently depending on whether --tags is provided:
- Without
--tags(implicit resolution): unpublishable tags on HEAD are silently filtered. The pre-publish listing shows only the publishable subset. If the filter empties the set,release-kit publishprintsNothing to publish.and exits 0. - With
--tags(explicit naming): if any named tag points at an unpublishable workspace,release-kit publishexits 1 with one error line per unpublishable tag, citingpackage.json#private. Explicit naming surfaces the contradiction rather than silently dropping the tag.
Example output when an explicit tag is unpublishable:
Error: basic-v1.0.0 (packages/basic) cannot be published: package.json#private is true.release-kit create-github-release
Create GitHub Releases from changelog.json for tags on HEAD. Independent of npm publish: invoking this command creates Releases regardless of whether the matching package was published.
| Flag | Description |
| ---------------------- | ------------------------------------------------------------------------- |
| --dry-run | Preview without creating releases |
| --tags=tag1,tag2,... | Only create releases for the named tags (comma-separated, full tag names) |
| --help, -h | Show help |
When --tags is omitted, every release tag pointing at HEAD is processed. The CLI requires the gh CLI on PATH and contents: write permission. The bundled create-github-release.reusable.yaml GitHub Actions workflow runs this command in CI.
release-kit show-tag-prefixes
Print a per-workspace table of derived tag prefixes, tag counts, and declared legacy prefixes. Also surfaces any release-shaped tag prefix in the repo that is neither a derived prefix nor declared via legacyIdentities, along with a copy-pasteable workspaces: [...] config snippet. The snippet uses a TODO-fill-in-legacy-npm-name placeholder for each identity's name; replace it with the package's prior npm name before pasting.
| Flag | Description |
| -------------- | ----------- |
| --help, -h | Show help |
Exits 0 when every workspace derives a prefix and there are no cross-workspace collisions; exits 1 on any derivation failure or collision. Undeclared candidates do not affect the exit code — they surface as a warning via the legacy tag prefixes are declared readyup check.
In single-package mode, prints a single row with workspacePath = . and derivedPrefix = v; legacy entries and undeclared-candidate scanning are not applicable.
release-kit init
Initialize release-kit in the current repository. By default, scaffolds only the GitHub Actions workflow file. Use --with-config to also scaffold configuration files.
| Flag | Description |
| --------------- | -------------------------------------------------------------------------- |
| --with-config | Also scaffold .config/release-kit.config.ts and .config/git-cliff.toml |
| --force | Overwrite existing files instead of skipping them |
| --dry-run | Preview changes without writing files |
| --help, -h | Show help |
Scaffolded files:
.github/workflows/create-github-release.yaml— workflow that creates a GitHub Release on tag push, independent of npm publish.github/workflows/publish.yaml— workflow that delegates to a reusable publish workflow.github/workflows/release.yaml— workflow that delegates to a reusable release workflow.config/release-kit.config.ts— starter config with commented-out customization examples (with--with-config).config/git-cliff.toml— copied from the bundled template (with--with-config)
release-kit work-types
Manage the canonical work-types taxonomy used by changelog and release-notes generation.
| Subcommand | Description |
| ---------- | ----------------------------------------------------------------------------------- |
| check | Compare the local work-types.json against the upstream codeassembly canonical |
| sync | Overwrite the local work-types.json with the upstream contents (after validation) |
check exit codes:
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------- |
| 0 | Match (or upstream missing — transitional warning printed) |
| 1 | Drift detected |
| 2 | Network error or non-OK HTTP response |
| 3 | Schema mismatch (upstream JSON does not parse or fails the top-level shape check) |
The check is non-blocking initially: until codeassembly publishes its work-types.json, the upstream URL returns 404 and check exits 0 with a warning. CI flip to a blocking check is tracked as a follow-up once the upstream ships.
These commands are also exposed as nmr work-types:check / nmr work-types:sync from any package directory.
Authenticated fetches
When the upstream codeassembly repo is private, both check and sync need a GitHub token to fetch the canonical work-types.json. Set GITHUB_TOKEN in the environment and the commands send Authorization: Bearer <token> automatically; without it, requests are unauthenticated and a private upstream will return 404.
# Source from `gh auth` for local runs:
export GITHUB_TOKEN=$(gh auth token)
pnpm exec release-kit work-types checkThe token needs contents: read on the codeassembly repo (fine-grained PAT scope) or the equivalent classic-PAT scope. A token without sufficient scope still produces a 404 — same response as a missing upstream — so a misconfigured token degrades to the transitional-warning path rather than failing loudly. CI wiring against private upstream is deferred until either codeassembly is publicly readable or a cross-repo PAT is provisioned as a workflow secret.
release-kit sync-labels
Manage GitHub label definitions via config-driven YAML files.
| Subcommand | Description | Flags |
| ---------- | -------------------------------------------------------------- | ---------------------- |
| init | Scaffold config, caller workflow, and generate labels | --dry-run, --force |
| generate | Regenerate .github/labels.yaml from config | — |
| sync | Trigger the sync-labels GitHub Actions workflow via gh CLI | — |
init scaffolds .config/sync-labels.config.ts with auto-detected workspace scope labels and a .github/workflows/sync-labels.yaml caller workflow, then generates .github/labels.yaml. generate reads the config and writes .github/labels.yaml. sync triggers the workflow remotely — it requires the gh CLI and an existing workflow file.
Published JSON Schema for .meta/label-map.json
release-kit publishes a JSON Schema for .meta/label-map.json — a separate, generic data file that maps commit-prefix scopes and types to GitHub label names. The schema lives at packages/release-kit/schemas/label-map.json in this repo and is reachable via the stable raw URL:
https://github.com/williamthorsen/node-monorepo-tools/raw/release-kit-v<version>/packages/release-kit/schemas/label-map.jsonConsumers reference it from the top of their .meta/label-map.json:
{
"$schema": "https://github.com/williamthorsen/node-monorepo-tools/raw/release-kit-v<version>/packages/release-kit/schemas/label-map.json",
"types": { "feat": "feature", "fix": "fix" },
"scopes": { "audit": "scope:audit" }
}release-kit publishes the schema only; it does not generate .meta/label-map.json. Generation requires commit-prefix knowledge that lives outside release-kit (in agent-conventions tooling), and is owned by those consumers.
GitHub Actions workflow
The init command scaffolds a release workflow at .github/workflows/release.yaml that delegates to a reusable release workflow. The scaffolded workflow accepts these inputs:
| Input | Type | Description |
| ------ | ------ | ------------------------------------------------------------------- |
| only | string | Workspaces to release (comma-separated, leave empty for all) |
| bump | choice | Override bump type: patch, minor, major (empty = auto-detect) |
For repos that need a self-contained workflow instead of the reusable one, the scaffolded file can be expanded. The key steps are: checkout with full history (fetch-depth: 0), run release-kit prepare with optional --only and --bump flags, check for changes, read tags from tmp/.release-tags, then commit, tag, and push.
Triggering a release
# All workspaces
gh workflow run release.yaml
# Specific workspace(s)
gh workflow run release.yaml -f only=arrays
gh workflow run release.yaml -f only=arrays,strings -f bump=minorOr use the GitHub UI: Actions > Release > Run workflow.
cliff.toml setup
The package includes a bundled cliff.toml.template that is used automatically when no custom config is found. The resolution order:
| Priority | Path | Notes |
| -------- | ----------------------------- | ----------------------------------------------- |
| 1 | cliffConfigPath in config | Explicit path, returned without existence check |
| 2 | .config/git-cliff.toml | Project-level override |
| 3 | cliff.toml | Repo root fallback |
| 4 | Bundled cliff.toml.template | Automatic fallback |
The bundled template provides a generic git-cliff configuration that:
- Strips issue-ticket prefixes matching
^[A-Z]+-\d+\s+(e.g.,TOOL-123,AFG-456) - Handles both
type: descriptionandworkspace|type: descriptioncommit formats - Groups commits by work type via
[git].commit_parsers
The body template is intentionally empty: release-kit reads cliff's --context JSON output and renders CHANGELOG.md in-process via renderChangelogMarkdown (see Editorial overrides for the rationale). The [git].commit_parsers section remains load-bearing for --context group assignment.
To customize, scaffold a local copy with release-kit init --with-config and edit .config/git-cliff.toml. Edit only the [git] section — body-template changes have no effect.
External dependencies
This package shells out to two external tools:
git— must be available onPATH. Used to find tags and retrieve commit history.git-cliff— automatically downloaded and cached vianpxon first invocation. No need to install it as a dev dependency.
Upgrading from v4 to v5
Release-kit v5 derives each workspace's tag prefix from its unscoped package.json name, so a package at packages/core with "name": "@scope/nmr-core" uses tags like nmr-core-v1.3.0. Repos that previously tagged under the directory basename (e.g., core-v1.3.0) do not need to rewrite history — declare the prior identity in legacyIdentities so release-kit recognizes historical tags under both the new and old prefixes.
Minimal worked example for a repo whose pre-v5 tags were core-v0.2.7 and whose npm name has not changed:
// .config/release-kit.config.ts
import type { ReleaseKitConfig } from '@williamthorsen/release-kit';
const config: ReleaseKitConfig = {
workspaces: [
{
dir: 'core',
legacyIdentities: [{ name: '@scope/nmr-core', tagPrefix: 'core-v' }],
},
],
};
export default config;Each legacyIdentity is a complete (name, tagPrefix) snapshot of the workspace at an earlier point. In the common case — the tag-derivation rule changed but the npm name did not — the identity's name equals the current name. If an earlier publish used a different npm name, use that prior name here.
Verify with release-kit show-tag-prefixes — it prints the derived prefix per workspace, tag counts under each declared legacy prefix, and any undeclared release-shaped prefixes it finds in the repo (with a copy-pasteable config snippet that includes a TODO-fill-in-legacy-npm-name placeholder to replace with the prior npm name). After declaring, release-kit prepare consults the union of the current and legacy tag prefixes when searching for the most recent baseline tag, and changelog generation matches tags under either prefix.
See the legacyIdentities entry in the WorkspaceOverride section for the config shape.
Using deriveWorkspaceConfig() for manual configuration
If you need to build a MonorepoReleaseConfig manually (e.g., for the legacy script-based approach), the exported deriveWorkspaceConfig() helper creates a WorkspaceConfig from a workspace-relative path. It reads the workspace's package.json to derive the tag prefix from the package name:
import { deriveWorkspaceConfig } from '@williamthorsen/release-kit';
// packages/arrays/package.json contains `"name": "@scope/arrays"`
deriveWorkspaceConfig('packages/arrays');
// => {
// dir: 'arrays',
// name: '@scope/arrays',
// tagPrefix: 'arrays-v',
// workspacePath: 'packages/arrays',
// packageFiles: ['packages/arrays/package.json'],
// changelogPaths: ['packages/arrays'],
// paths: ['packages/arrays/**'],
// }dir is the basename of the workspace path and is the stable internal identifier used by --only, WorkspaceOverride.dir, and the dependency graph. tagPrefix is derived from the unscoped package.json name — any leading @scope/ is stripped — so tags reflect the package identity rather than the directory layout. For example, a workspace at packages/core with "name": "@williamthorsen/nmr-core" produces tagPrefix: 'nmr-core-v', yielding tags like nmr-core-v1.3.0.
The workspace's package.json must declare a non-empty name field; deriveWorkspaceConfig() throws otherwise. If two workspaces produce the same tagPrefix (because their unscoped names collide), mergeMonorepoConfig() throws and names the colliding workspaces so you can rename one.
Legacy script-based approach
The CLI-driven approach is recommended for new setups. The script-based approach (using runReleasePrepare with a manually maintained config) is still supported for backward compatibility.
// .github/scripts/release.config.ts
import type { MonorepoReleaseConfig } from '@williamthorsen/release-kit';
import { deriveWorkspaceConfig } from '@williamthorsen/release-kit';
export const config: MonorepoReleaseConfig = {
workspaces: [deriveWorkspaceConfig('packages/arrays'), deriveWorkspaceConfig('packages/strings')],
formatCommand: 'npx prettier --write',
};// .github/scripts/release-prepare.ts
import { runReleasePrepare } from '@williamthorsen/release-kit';
import { config } from './release.config.ts';
runReleasePrepare(config);The key difference: the script-based approach requires manually listing every workspace, while the CLI auto-discovers them from pnpm-workspace.yaml.
Breaking changes
resolveReleaseTags takes workspaces; WorkspaceConfig requires workspacePath
Tag resolution is now driven by workspace records rather than a caller-supplied directory map, so resolveReleaseTags can report both the workspace dir and its workspacePath for every resolved tag.
resolveReleaseTagssignature changed from(workspaceMap?: Map<string, string>)to(workspaces?: readonly WorkspaceConfig[]).WorkspaceConfiggained a requiredworkspacePath: stringfield.
Replace direct Map-based calls with deriveWorkspaceConfig(), which now populates workspacePath for you:
-import { resolveReleaseTags } from '@williamthorsen/release-kit';
-
-const workspaceMap = new Map([['core', 'packages/core']]);
-resolveReleaseTags(workspaceMap);
+import { deriveWorkspaceConfig, resolveReleaseTags } from '@williamthorsen/release-kit';
+
+resolveReleaseTags([deriveWorkspaceConfig('packages/core')]);If you construct WorkspaceConfig objects directly, add workspacePath alongside the other required fields.
release-kit publish and release-kit push replace --only with --tags
The --only=<dir> flag on release-kit publish and release-kit push has been removed. Both commands now filter by full tag name via --tags=<tag1>[,<tag2>...], matching release-kit create-github-release. Passing --only=... after upgrading produces an Unknown option: --only error.
Local usage mapping:
-release-kit publish --only=core
+release-kit publish --tags=core-v1.3.0
-release-kit push --only=core,cli
+release-kit push --tags=core-v1.3.0,cli-v0.5.0Omitting --tags preserves the previous behavior of operating on every release tag at HEAD. The reusable workflow publish.reusable.yaml also accepts an optional tags: input, and the scaffolded publish.yaml now passes tags: ${{ github.ref_name }} so the publish scope is explicit rather than relying on actions/checkout@v6's fetch default. Existing callers that do not set tags: continue to work unchanged.
GitHub Release creation moved to its own command and workflow
release-kit publish no longer creates GitHub Releases as a side effect, and the releaseNotes.shouldCreateGithubRelease config field has been removed. Adoption is now signaled by installing the dedicated create-github-release.reusable.yaml workflow.
If you previously set the field, remove it from .config/release-kit.config.ts. The new caller template (scaffolded by release-kit init) looks like this:
name: Create GitHub Release
on:
push:
tags:
- '*-v[0-9]*.[0-9]*.[0-9]*'
permissions:
contents: write
jobs:
create-github-release:
uses: williamthorsen/node-monorepo-tools/.github/workflows/create-github-release.reusable.yaml@workflow/create-github-release-v1
with:
tag: ${{ github.ref_name }}The CLI command was renamed from release-kit github-release to release-kit create-github-release, and its filter flag changed from --only=<package-name> to --tags=<full-tag-name>[,...].
v1.1.0: formatCommand receives file paths as trailing arguments
Previously, formatCommand was executed as-is (e.g., pnpm run fmt would run without arguments). Now, the paths of all modified files (package.json files and changelogs) are appended as trailing arguments.
If your format command does not accept file arguments, update it to one that does:
-formatCommand: 'pnpm run fmt',
+formatCommand: 'npx prettier --write',v1.1.0: git-cliff is no longer a required dev dependency
git-cliff is now invoked via npx --yes git-cliff instead of requiring it as a dev dependency. You can remove it from your devDependencies. The version is not pinned, so npx downloads and caches the latest version on first invocation. To pin a specific version, use npx --yes [email protected] by wrapping the call in a custom script.
Migration from changesets
- Add
@williamthorsen/release-kitas a dev dependency. - Remove
@changesets/clifrom dev dependencies. - Delete the
.changeset/directory. - Run
npx @williamthorsen/release-kit initto scaffold workflow and config files. - Remove
changeset:*scripts frompackage.json(no replacement needed — the CLI handles everything). - Create an initial version tag for each package (e.g.,
git tag v1.0.0orgit tag arrays-v1.0.0).
No cliff config copy is needed — the bundled template is used automatically. To customize, run release-kit init --with-config.
