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

Changesets release pipeline A four-stage pipeline showing a contributor adding a changeset, CI running the version command to bump versions and write changelogs, then publishing to npm, and finally updating the dist-tag. Versioning & Release Flow Add changeset per pull request Version bump + changelog Publish npm publish dist-tag latest / next

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/ or package.json if no .changeset/*.md file is present in the diff (Changesets ships a changeset status --since=main command 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 (and changeset publish) applies the latest dist-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 of latest.

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.

Which distribution changes are breaking A matrix of packaging changes and the version bump each deserves. Adding an export subpath or a new condition is a minor release. Removing a condition, narrowing a type, dropping a Node version, or changing the default export shape are all major releases even when the runtime code is untouched. Packaging changes are API changes change to the distribution correct bump, and why add a new ./feature subpath to exports minor — nothing that resolved before stops resolving remove the require condition major — every CommonJS consumer breaks at install narrow a parameter type in .d.ts major — compiles yesterday, fails to compile today raise engines.node from 18 to 20 major — a supported runtime is being dropped Rule of thumb: if a consumer's build could fail after `npm update`, it is a major.

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.

Anatomy of an actionable changelog entry A four-part structure for a changelog entry: a one-line statement of the observable change, the audience it affects, the symptom a consumer would see, and the exact code edit needed to migrate. Four parts of an entry a reader can act on 1. observable change stated from the consumer's side "the require condition now points" "at a real CommonJS build" 2. who is affected name the consumer shape "CommonJS consumers on Node 18" "bundler users are unaffected" 3. the symptom the literal error text they will see ERR_PACKAGE_PATH_NOT_EXPORTED so search engines can match it 4. the migration one concrete edit, not advice "replace the deep import with" "the ./feature subpath"

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.1beta.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



Back to CI/CD, Publishing & npm Provenance