Versioning & Changelog Automation
Automate semantic versioning, changelogs, and npm dist-tags for library releases with Changesets and conventional commits across single and multi-package repos.
Hand-written version bumps and changelogs drift out of sync with reality the moment more than one contributor is shipping changes — a minor release gets tagged as patch, an entry is forgotten, or two people bump the same version number in parallel pull requests. This section covers automating both concerns with Changesets, the de facto standard for versioning npm packages from monorepos and single-package repositories alike, and shows how it interacts with the exports field your dual-format build already relies on.
Prerequisites
Before adopting automated versioning, confirm:
Canonical Configuration Block
Initialize Changesets once per repository; it creates a .changeset/config.json file that governs how versions are bumped and changelogs are written.
npx changeset init
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
access: "public" is required for scoped packages (@acme/sdk) to publish publicly rather than defaulting to npm’s private-by-default behavior for scopes. updateInternalDependencies: "patch" controls how a bump in one workspace package ripples to internal dependents — set it to "minor" if you want internal consumers to receive a larger bump whenever a dependency changes.
Release Flow
Step-by-Step Implementation
Step 1 — Record intent-to-release with a changeset
Whenever a pull request changes published behavior, run changeset to describe it. This is an interactive prompt that writes a markdown file, not a version bump itself:
npx changeset
🦋 Which packages would you like to include? · @acme/sdk
🦋 Which packages should have a major bump? · (none selected)
🦋 Which packages should have a minor bump? · (none selected)
🦋 Which packages should have a patch bump? · @acme/sdk
🦋 Please enter a summary for this change
🦋 Fix ERR_REQUIRE_ESM for CJS consumers by adding a require condition
This produces a file like .changeset/tall-cats-jam.md:
---
"@acme/sdk": patch
---
Fix ERR_REQUIRE_ESM for CJS consumers by adding a require condition
Commit this file alongside the code change in the same pull request. Multiple changesets can accumulate on main between releases — nothing is bumped or published until Step 2 runs.
HAZARD PREVENTION
Symptom: A release ships with no changelog entry for a change that clearly altered behavior.
Root cause: The pull request that introduced the change never ran
npx changeset, so there was nothing for the version command to consume.Fix: Add a CI check that fails a pull request touching
src/orpackage.jsonif no.changeset/*.mdfile is present in the diff (Changesets ships achangeset status --since=maincommand for exactly this check).
Step 2 — Bump versions and generate the changelog
Run the version command to consume every pending changeset, bump each affected package’s version field according to semver, delete the consumed changeset files, and prepend entries to CHANGELOG.md:
npx changeset version
--- a/package.json
+++ b/package.json
@@
- "version": "2.0.0",
+ "version": "2.0.1",
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@
+## 2.0.1
+
+### Patch Changes
+
+- Fix ERR_REQUIRE_ESM for CJS consumers by adding a require condition
+
## 2.0.0
This step never touches the npm registry — it only edits files in your working tree. Review and commit the result like any other change.
Step 3 — Publish with the correct dist-tag
npx changeset publish
changeset publish runs npm publish for every package whose version no longer matches what’s on the registry, tagging each with latest by default. For prerelease builds, see Managing Prerelease and dist-tags on npm, which covers npx changeset pre enter beta and publishing without disturbing the default tag.
HAZARD PREVENTION
Symptom: A patch release accidentally becomes the default install for users, even though it was meant as an internal preview.
Root cause:
npm publish(andchangeset publish) applies thelatestdist-tag unless told otherwise, regardless of the version’s semver prerelease suffix.Fix: Enter Changesets’ pre-release mode (
npx changeset pre enter <tag>) before publishing previews, which automatically applies the matching dist-tag instead oflatest.
Step 4 — Automate the release PR in CI
The changesets/action GitHub Action watches main for pending changesets. When it finds them, it opens (or updates) a “Version Packages” pull request running Step 2 automatically; when that PR is merged, the same workflow run publishes to npm:
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Create release PR or publish
uses: changesets/action@v1
with:
publish: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
For the full picture of the surrounding publish pipeline — including provenance attestation and OIDC — see Automating npm Releases with GitHub Actions.
Tooling Validation
Check for pending changesets before merging, so a release branch never silently loses a changelog entry:
npx changeset status --since=main
Sample output when a changeset is missing:
🦋 warn It seems this repo does not have any changesets.
🦋 warn Warning: this workflow will not create a release.
Sample output when everything is wired correctly:
🦋 info @acme/sdk: patch
🦋 This Changeset release will release 1 package
Compatibility Matrix
| Feature | Changesets | Conventional commits + semantic-release | Manual npm version |
|---|---|---|---|
| Monorepo/workspace aware | Yes (native) | Partial (plugins required) | No |
| Changelog generation | Yes, per-package | Yes | No |
| Requires strict commit message format | No | Yes | No |
| Prerelease / dist-tag support | Yes (changeset pre) |
Yes (branch-based) | Manual |
| Human-editable release notes | Yes (edit the changeset file) | No (derived from commits) | Yes |
Semver Decisions the Tooling Cannot Make for You
Automation can compute a version number from a commit message, but it cannot decide whether a change is breaking — and for a dual-format TypeScript package the breaking-change surface is much wider than the public API. Four categories of change routinely ship as patches when they are semantically major, and every one of them produces a support thread rather than a compiler error.
The type-narrowing case deserves particular attention because it is so easy to do accidentally. Changing a parameter from string | number to string is a strictly-smaller accepted input set: every consumer passing a number now fails to compile. Nothing in the runtime changed, no test broke, and a commit-message-driven release tool will happily label it fix: and cut a patch. The defence is a type-level regression test — an expectTypeOf suite or a set of // @ts-expect-error assertions committed alongside the source — so the compiler notices what a human review missed.
The same logic applies in reverse to dependencies. Moving a package from dependencies to peerDependencies is a major change: consumers who never installed it directly now get an unmet-peer warning and, on stricter package managers, a hard resolution failure. Moving in the other direction is a minor, because nothing a consumer had stops working.
Writing Changelogs Consumers Can Act On
An automated changelog is only useful if a reader can decide, in one pass, whether the release affects them. Generated entries default to describing what the author did (fix: handle undefined in parse); what a consumer needs is what changes for them (parse() no longer throws on an undefined input; it returns null). The gap between those two sentences is where most upgrade friction lives.
Changesets encourages this shape naturally, because the summary is written when the change is fresh rather than reconstructed at release time from commit subjects:
---
"@scope/my-library": major
---
`createClient()` no longer accepts a bare string URL.
CommonJS and ESM consumers are both affected. Passing a string now fails to
compile with `Argument of type 'string' is not assignable to parameter of
type 'ClientOptions'`.
Migrate by wrapping the value:
- createClient("https://api.example.com")
+ createClient({ baseUrl: "https://api.example.com" })
That file lives in .changeset/, gets reviewed in the same pull request as the code, and ends up verbatim in CHANGELOG.md — which means the reviewer of the change is also the reviewer of the migration instructions. The mechanics of wiring this up are covered in Automating Changelogs with Changesets.
Two conventions keep generated changelogs readable over years rather than months. First, keep a stable heading structure — ### Breaking, ### Added, ### Fixed — so a consumer scanning three releases at once can skip straight to the section that matters. Second, never delete or rewrite a published entry: if an entry was wrong, add a correction to the next release. Consumers link to changelog anchors from their own upgrade notes, and rewriting history breaks those links silently.
Coordinating Versions Across Several Packages
A single package has one version and one decision. A workspace publishing several packages has a graph, and the interesting choice is whether versions move together (fixed) or independently.
Fixed versioning bumps every package to the same number on every release. It makes compatibility trivial to reason about — @scope/[email protected] and @scope/[email protected] are always designed to work together — at the cost of publishing unchanged packages, which produces releases with empty changelogs and makes “what actually changed?” harder to answer.
Independent versioning bumps only the packages whose source changed, plus their dependents whose internal ranges must be updated. Changelogs stay honest, but a consumer must now reason about which combinations are compatible, and the release tool must correctly cascade a major bump in a leaf package to every package that re-exports it.
The middle path most workspaces converge on is independent versioning with a linked group: the packages that are genuinely coupled (a core package and its framework adapters) move in lockstep, while genuinely standalone packages version on their own. Changesets expresses this with a linked array in its config, and getting the grouping right matters more than the tool — a package placed in the wrong group either publishes noise forever or silently ships an incompatible pair. Where the workspace mechanics themselves are the question, Monorepo & Workspace Publishing covers the build-order and workspace: protocol side of the same problem.
Pre-Releases as a Safety Valve
Every risky release benefits from an audience of volunteers before it becomes the default. npm’s dist-tags provide that mechanism directly: a version published under a tag other than latest is installable by name but is never selected by npm install my-library, so it reaches only the people who ask for it.
The convention that works across ecosystems is a hyphenated pre-release identifier matched to a dist-tag of the same name:
npm version 3.0.0-beta.1 --no-git-tag-version
npm publish --tag beta --provenance --access public
Consumers opt in explicitly, and semver’s precedence rules keep them safe by default — ^2.9.0 will never resolve to 3.0.0-beta.1, because pre-release versions are excluded from ranges unless the range itself names a pre-release.
npm install @scope/my-library@beta # opt in
npm dist-tag ls @scope/my-library # see who points where
beta: 3.0.0-beta.1
latest: 2.9.4
Promotion is a metadata move, not a rebuild: once the beta has survived contact with real consumers, publish the final version and point latest at it. Crucially, the artefact that was tested is not the artefact that ships — 3.0.0 is a fresh publish — so the pre-release cycle should end with the same validation chain as any other release rather than a shortcut on the grounds that “the beta was fine”.
Three tags cover almost every workflow. next for the upcoming major line, beta for versions under active feedback, and canary for automatic publishes from the main branch on every merge. Canary releases in particular pair well with a version scheme derived from the commit (3.0.0-canary.a1b2c3d), because it makes bug reports unambiguous — the reporter’s version string names the exact commit to check out.
Two operational rules prevent the common accidents. Never publish a pre-release without --tag, because npm defaults to latest and a single omitted flag hands every consumer an unfinished major. And never leave a stale tag pointing at a yanked version: after promoting a beta, either move the tag forward or remove it (npm dist-tag rm @scope/my-library beta), so a consumer who follows install instructions from an old blog post does not land on an abandoned branch of your release history. The day-to-day mechanics, including recovering from a mis-tagged publish, are covered in Managing Prerelease and Dist-Tags on npm.
Finally, treat the changelog for a pre-release as a draft of the real one. Accumulating 3.0.0-beta.1 … beta.4 entries and then shipping 3.0.0 with a fifth, separate entry forces consumers to read five fragments to understand one migration. Collapse them: the final release note should describe the whole journey from 2.x, with the beta history kept as detail rather than as the headline.
One last habit makes pre-releases worth the effort: publish the beta with a deadline and say so in the release note. “Feedback welcome until the 14th, after which this ships as 3.0.0” converts a silent tag into an actual review window, and gives the maintainers a defensible answer when somebody reports a breaking change three months after the major landed. A pre-release nobody was told about provides no more safety than publishing straight to latest.
Guides in This Section
- Automating Changelogs with Changesets — a closer look at changeset file formats, monorepo linking strategies, and custom changelog generators.
- Managing Prerelease and dist-tags on npm — publishing beta and canary builds without disturbing the
latesttag that most consumers install by default.
Related
- Automating npm Releases with GitHub Actions — the CI pipeline that wraps the version-and-publish flow described here, including provenance attestation.
- Publishing Packages from a pnpm Workspace — how Changesets’
fixedandlinkedpackage groups interact with workspace protocol dependencies across multiple published packages. - Validating Packages Before Publish — run these checks before
changeset publishexecutes, so a version bump never ships a broken exports map.