Back to publishing & release Automate semantic versioning Publish to the npm registry Harden the supply chain

Semantic Versioning and Release Automation

Without an enforced versioning contract and an automated bump, releases drift: humans forget to increment, ship breaking changes as patches, and tag the wrong commit — and every downstream npm ci inherits the mistake. This guide turns version selection into a deterministic, commit-driven step inside CI.

Where This Fits

Versioning is the second stage of Package Publishing & Release Engineering: after the build produces an artifact and before it is packed and published, you must decide what number it carries. That number is the public contract consumers pin against, so it has to be derived mechanically from what actually changed, not chosen by hand under deadline pressure. The two dominant approaches are intent files committed alongside changes, detailed in Automating Releases with Changesets, and version inference from commit messages, detailed in Configuring Conventional Commits and semantic-release.

Where This Fits Versioning is the second stage of Package Publishing & Release Engineering: after the build produces an artifact and bef Where This Fits Versioning is the second stage of Package Publishing & Release Engineering: after the build produces an artifact and before it is packed and published, you must
Where This Fits — the core idea of this section at a glance.

Semantic Versioning Rules

A semver string is MAJOR.MINOR.PATCH, optionally followed by a prerelease identifier and build metadata: 2.4.1-beta.3+build.07. Each segment carries a promise.

Semver bump rules Which change forces which version part. Change Bump Signal breaking API major consumers must adapt new feature minor backward compatible bug fix patch safe upgrade
The kind of change dictates the version part, not the calendar.
  • MAJOR — incompatible API changes: removals, renames, stricter signatures, raised engines floors, changed default behavior.
  • MINOR — backward-compatible additions: new exports, new optional parameters, new opt-in behavior.
  • PATCH — backward-compatible bug fixes only; no surface change.

The hard rule for libraries: classify the bump by impact on the public API, never by how much code changed. A one-line fix that alters a return type is a breaking change; a thousand-line internal refactor that preserves the surface is a patch. Versions below 1.0.0 are treated as unstable — the spec allows 0.x minor bumps to break — so reaching 1.0.0 is the commitment to honor the contract.

# semver precedence (lower to higher)
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0
# build metadata is ignored for precedence
1.0.0+build.1 == 1.0.0+build.2

The discipline that makes semver useful is deriving the bump from the nature of the change, not from how big it feels. Any change a consumer could observe as a break — a removed or renamed export, a stricter type signature, a changed default, a raised engines floor — is a major, regardless of how small the diff. A backward-compatible addition, like a new optional parameter or a new export, is a minor. A fix with no API change is a patch. The contract is only as trustworthy as your consistency in applying it: ship one breaking change in a minor and every downstream caret range breaks silently.

Type-level changes deserve special attention because they are easy to under-classify. Tightening a parameter type, narrowing a return type, or making an optional field required are all breaking for TypeScript consumers even when the runtime behavior is unchanged, because their build will now fail to compile against your new types. Treating the type surface as part of the public API — and bumping accordingly — is what keeps a package trustworthy for the typed ecosystem that depends on it.

Ranges: What Consumers Actually Pin

Consumers rarely pin exact versions; they pin ranges, and the range operator decides how far an automatic update may travel. Understanding this from the publisher side tells you exactly how much blast radius a given bump has.

Ranges: What Consumers Actually Pin Consumers rarely pin exact versions; they pin ranges, and the range operator decides how far an automatic update may tra Ranges: What Consumers Actually Pin Consumers rarely pin exact versions; they pin ranges, and the range operator decides how far an automatic update may travel.
Ranges: What Consumers Actually Pin — the core idea of this section at a glance.
Range Matches Stops at
^2.3.0 (caret) >=2.3.0 <3.0.0 next major
~2.3.0 (tilde) >=2.3.0 <2.4.0 next minor
2.3.0 (exact) only 2.3.0 nothing else
>=2.3.0 any newer version nothing — dangerous
2.x / 2.3.x within that major/minor the wildcard segment

Caret is the npm default and the reason a minor bump reaches every ^ consumer automatically — and why a breaking change mislabeled as minor is so destructive. Because prerelease versions sort below their release, ^2.0.0 never matches 3.0.0-beta.0; prereleases require an explicit dist-tag opt-in. How consumers resolve these ranges into a locked tree is covered in Lockfile Management Strategies.

Ranges express how much drift a consumer accepts, and understanding them clarifies why your bump discipline matters so much. A caret (^2.3.0) admits any compatible 2.x, so a consumer automatically receives your minors and patches; a tilde (~2.3.0) admits only patches; an exact pin accepts nothing new. Because most consumers use carets, a mislabeled minor that actually breaks reaches them on their next install without any action on their part — the bump discipline is what stands between your change and a broken downstream build.

Libraries and applications pin differently, and both are correct for their role. A library should keep its own dependency ranges reasonably wide so consumers can deduplicate shared packages into a single copy rather than being forced into a narrow version; an application pins more tightly and relies on its lockfile for exactness, since nothing depends on an application's declared ranges. Matching the pinning strategy to whether you are consumed or deployed avoids both the over-constrained library that causes duplication and the under-pinned application that drifts.

Dist-Tags and Prerelease Channels

A dist-tag is a named pointer on the registry to a specific published version. latest is the default a bare npm install resolves; everything else is an opt-in channel.

# Stable release lands on latest
npm publish

# Release candidate on its own channel — latest is untouched
npm version 3.0.0-rc.0
npm publish --tag next

# Move a tag without republishing (e.g. promote rc to latest later)
npm dist-tag add @scope/pkg@3.0.0 latest
npm dist-tag ls @scope/pkg

Use prerelease identifiers — alpha, beta, rc — to stage a major release. Cut 3.0.0-alpha.0 early for adventurous users, progress through beta and rc as it stabilizes, then publish 3.0.0 to latest. Each step ships under --tag next or --tag beta so the stable line is never disturbed. The cardinal mistake is publishing a prerelease without a tag, which overwrites latest and breaks every default install.

Commit-driven release flow Commits are analyzed to infer a version bump, which feeds version, changelog, tag, and publish steps, with a separate prerelease channel. commits feat / fix / ! analyze infer bump patch/minor/major version bump + tag changelog publish latest dist-tag From commit history to a tagged release the bump is derived, never chosen by hand prerelease channel beta / rc published under --tag next
Commit-driven release: the version bump is inferred from commit types, then version, changelog, tag, and publish run as one chain; prereleases branch to their own dist-tag.

Dist-tags are named pointers to versions, and they are what let you publish a version without making it the default install. The latest tag is what a bare npm install pkg resolves, so moving latest is how you promote a release; other tags like next, beta, or canary make a version installable only by consumers who ask for it explicitly. This separation between publishing and promoting is the mechanism behind every staged rollout: a version can exist, be installed by early adopters, and gather real-world signal long before it becomes the default anyone gets.

Prerelease identifiers add a second layer of safety on top of dist-tags. A version like 3.0.0-rc.1 sorts below its stable counterpart in semver ordering, so a caret range such as ^2.0.0 never resolves to it and even ^3.0.0 excludes it unless the range itself carries a prerelease tag. This means a release candidate cannot accidentally reach a consumer who did not opt in, which is exactly the guarantee you want when shipping something you are not yet confident in. Together, dist-tags and prerelease identifiers turn a risky major into a controlled progression from opt-in channel to promoted default.

Automated Version Bumping

Automation removes two failure points: choosing the wrong bump and tagging the wrong commit. Both leading tools read your change history and compute the next version deterministically.

Automated bump Commits classify the change and compute the next version. conventional commits feat / fix / ! classify highest bump wins next version computed tag
Commit metadata computes the next version deterministically.

With changesets, each pull request adds a small markdown file declaring the bump level and a human summary. At release time the tool aggregates pending changesets, applies the highest level, writes the new versions and changelog, and opens a release pull request. This shines in monorepos because each package gets its own correctly-scoped bump from one set of changeset files. The full setup is in Automating Releases with Changesets.

With semantic-release, there are no intent files: the version is inferred entirely from Conventional Commit messages on the release branch. fix: yields a patch, feat: a minor, and a feat!: or BREAKING CHANGE: footer a major. It then versions, generates the changelog, tags, and publishes in one unattended run. Setup, commitlint enforcement, and plugins are covered in Configuring Conventional Commits and semantic-release.

# Manual equivalent, for understanding what automation does
npm version minor             # bumps package.json + creates git tag
git push --follow-tags        # push commit and the new tag together
npm publish --access public

Automating the bump removes the single manual step where a version mistake enters: a human choosing and typing a number. In an automated pipeline the version is computed from the accumulated changes — the highest bump implied by the conventional commits since the last release, or the aggregate of the changeset intent files — so a publish can never reuse a version or ship a bump that does not match what changed. The tool reads the changes, computes the next version, updates the manifest, and hands off to the publish step, all without judgment calls that can drift.

In a monorepo the computation is per package, which is what makes independent versioning tractable. The tool determines which packages each change touched, walks the dependency graph to include packages that must move because a dependency did, and bumps each accordingly — so a consumer of one package is not forced to upgrade because an unrelated sibling shipped a major. Getting this right by hand across many packages is effectively impossible, which is why monorepo release automation is less a convenience than a requirement at any real scale.

With Changesets, each change carries an intent file that declares the bump, and CI computes and applies versions from the accumulated intents:

pnpm changeset            # author an intent file describing the change
pnpm changeset version    # compute + apply versions, update changelogs
pnpm changeset publish    # publish the bumped packages

Because the version is derived from the intent files rather than typed, a publish can never reuse a version, and a required changeset status --since=origin/main check fails a pull request that touches a publishable package without a changeset — so the intent is never forgotten. In a monorepo this computes each package's next version independently and walks the graph to bump packages that must move because a dependency did, which is the per-package precision that makes independent versioning tractable at scale.

Changelog Generation

A changelog is the human-readable counterpart to the version number. Both tool families generate it from structured input — changeset summaries or Conventional Commit subjects — grouped into Added / Fixed / Breaking sections per version. Generating it mechanically guarantees the changelog never drifts from what actually shipped, and the same parsed data drives the version decision, so the two can never disagree.

Changelog Generation A changelog is the human-readable counterpart to the version number. Changelog Generation A changelog is the human-readable counterpart to the version number.
Changelog Generation — the core idea of this section at a glance.

A changelog is the human-readable face of your versioning, and generating it from the same source as the bump keeps the two honest. Commit-driven tools assemble the changelog from the conventional commit subjects grouped by type; intent-driven tools assemble it from the prose a contributor wrote in each changeset. Either way, the changelog entry and the version bump derive from one source of truth, so they cannot disagree — the version says what kind of change shipped, and the changelog says what it was.

The quality of the changelog is a function of the discipline feeding it. Terse or misleading commit messages produce a useless changelog; thoughtful changeset descriptions produce one consumers actually rely on to decide whether to upgrade. This is an argument for the intent-driven model where consumer-facing clarity matters, since it separates the changelog entry (written for a human deciding whether to upgrade) from the commit message (written for a developer reading history) and lets each be good at its job.

A changelog is only valuable if it tells a consumer what they need to decide whether to upgrade, which is an argument for generating it from a source written for that audience. Commit-driven tools assemble it from conventional commit subjects, which are written for developers reading history and are often too terse or internal to help a consumer; intent-driven tools assemble it from changeset descriptions written specifically to explain a change's impact. The difference shows up precisely when it matters most — a consumer deciding whether a minor bump is safe to take reads the changelog, and a good one answers the question while a mechanical one does not.

Whichever source feeds it, generating the changelog from the same input as the version bump keeps the two honest. The version encodes the kind of change; the changelog describes what it was; and because both derive from one source of truth — the commits or the changesets — they cannot disagree. Automating the changelog also removes the manual step where entries are forgotten or written inconsistently, so every release ships with an accurate, complete record rather than a changelog that reflects whoever remembered to update it.

CI Release Flow

A robust release workflow gates the publish behind a green build and tests, installs from a frozen lockfile, and uses an OIDC-issued identity for provenance rather than a static token where possible. This skeleton runs the inferred bump and publish on a push to main:

CI release flow Merge triggers version, changelog and publish. merge to main release trigger version + changelog from commits publish registry + git tag
A merge to the release branch runs the whole release unattended.
# .github/workflows/release.yml
name: release
on:
  push:
    branches: [main]               # release only from the protected branch
concurrency: release-${{ github.ref }}   # never two releases at once
permissions:
  contents: write                  # push the version commit + git tag
  id-token: write                  # OIDC token for npm provenance
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0           # full history so the bump can be inferred
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'  # writes auth into .npmrc
          cache: 'npm'
      - run: npm ci                # frozen, reproducible install
      - run: npm run build         # produce dist/ before versioning
      - run: npm test              # a failing test blocks the release
      - name: Version and publish
        run: npx semantic-release  # infer bump, changelog, tag, publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  # create release + tag
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}  # registry auth

The annotations that matter most: fetch-depth: 0 is mandatory because version inference needs the full commit history, not a shallow clone; concurrency prevents two release jobs from racing to publish the same version; and id-token: write is what lets the publish step attach provenance. Swap the final step for the changesets action if you use intent files instead of commit inference.

Pitfalls & Remediation

Mistake Impact Remediation
Breaking change shipped as a minor Every ^ consumer breaks on update. Classify by public-API impact; mark breaking commits with ! or BREAKING CHANGE:.
Prerelease published without --tag Unstable build overwrites latest. Always publish prereleases under --tag next/beta; promote with dist-tag add.
Shallow clone in CI (fetch-depth: 1) Version inference sees no history and fails or mis-bumps. Set fetch-depth: 0 on checkout.
Manual npm version plus manual publish Drift between tag, changelog, and published version. Run versioning and publish in one automated step.
No concurrency guard on the release job Two pushes race and one publish fails or duplicates. Add a concurrency group keyed on the ref.
Pitfalls & Remediation Pitfalls & Remediation in production JavaScript package workflows. Pitfalls & Remediation Pitfalls & Remediation in production JavaScript package workflows.
Pitfalls & Remediation — the core idea of this section at a glance.

Prerelease channels and staged rollout

Dist-tags decouple publishing a version from promoting it to the default latest that most installs resolve, which is what makes a staged rollout of a risky release possible. Publishing 3.0.0-beta.1 under a beta tag makes it installable only by consumers who explicitly opt in, so you can validate a breaking major in the wild while every existing consumer stays on the stable line untouched. Prerelease identifiers sort below their stable counterpart, so a caret range never accidentally resolves to a release candidate — exactly the safety you want when shipping something you are not yet sure of.

Staged rollout Publish behind a channel, validate, promote to latest. publish 3.0.0-beta opt-in only validate in the wild real signal dist-tag add latest promote when ready
A dist-tag lets a risky release reach opt-in users before it becomes the default.

Promotion is then a metadata operation, not a rebuild. Once a prerelease proves out, npm dist-tag add pkg@3.0.0 latest moves the default tag to the already-published, already-verified version, so consumers on carets begin receiving it on their next install. This staged path — publish behind a channel, gather real-world signal, promote when confident — turns a major release from a single high-stakes moment into a controlled progression, and it is the mechanism behind canary and next channels that let early adopters exercise a release before it becomes the default.

Choosing between commit-driven and intent-driven automation

Release automation comes in two philosophies, and the choice shapes your team's daily workflow. Commit-driven tools like semantic-release infer the version bump from conventional commit messages, so nothing is ever forgotten but correctness depends entirely on commit discipline — a mistyped commit type ships the wrong bump. Intent-driven tools like Changesets ask contributors to write an explicit changeset describing each change, which is more work per change but produces curated, human-readable changelogs and handles independently-versioned monorepo packages naturally.

Automation models Commit-driven versus intent-driven release automation. Model Intent from Best for semantic-release commit messages single packages Changesets intent files monorepos + changelogs
Commit-driven never forgets but needs discipline; intent-driven curates changelogs.

The practical dividing line is repository shape. A single package where every commit already follows a convention is well served by semantic-release's zero-extra-step automation. A monorepo publishing several packages with changelogs a human will actually read usually prefers Changesets, with a CI status check that fails a pull request touching a package without a changeset so the intent is never forgotten. Whichever you choose, the goal is the same: derive the version from the changes rather than a person typing it, so a release can never reuse a version or ship a bump that does not match what changed. Enforce the chosen model in CI, and the last manual step where a mistake could enter is removed.

The CI release flow end to end

A fully automated release ties versioning, changelog, build, and publish into one hands-off flow triggered by a merge to the release branch. The job checks out with full history, installs with a frozen lockfile, computes the next version and changelog from the accumulated changes, builds and validates the artifact, publishes with provenance, and tags the commit — with no human choosing a version or running a publish command. Each stage is a gate that can stop the release, so a failed build, a broken exports map, or an audit breach blocks the publish rather than shipping a bad version.

Hands-off release Merge triggers version, build, publish, tag. merge to release trigger version + changelog from changes build + validate gated publish + tag with provenance
Deriving or verifying every input turns the last mile into the most reproducible stage.

What makes this safe is that every input is derived or verified rather than typed. The version comes from the changes, so it cannot collide with an existing one; the credential is a short-lived OIDC identity, so there is nothing to leak or rotate; the tarball contents come from the files allowlist, so nothing leaks; and the provenance attestation ties the result back to the exact commit and build. Automating the last mile this way converts publishing from the riskiest manual step in the lifecycle into its most reproducible one, which is the entire point of treating release engineering as code.

Common versioning mistakes and how to prevent them

The recurring versioning failures are all preventable by removing human judgment from the mechanical parts. Publishing over an existing version happens because a bump was forgotten — prevented by computing the version automatically. Shipping a breaking change in a minor happens because a human under-classified the change — prevented by deriving the bump from conventional commits or an explicit changeset that a reviewer sees. A missing changelog entry happens because the changelog is maintained by hand — prevented by generating it from the same source as the bump.

Prevent by deriving Derive the version, enforce it in CI, catch mistakes in review. derive the bump from changes enforce in CI status check caught in review not by consumers
Deriving the release from changes turns versioning mistakes into red builds, not shipped facts.

The unifying prevention is to make the release derive from the changes and to enforce that derivation in CI. A required check that fails a pull request touching a publishable package without a changeset catches the missing intent; a commit-message linter catches the malformed convention; a version-collision check catches a reused version before publish. Each turns a class of mistake from something that ships to consumers into a red build on the pull request that caused it, which is where a versioning mistake is cheap to fix rather than an immutable, published fact.

Frequently Asked Questions

Should I start a new library at 1.0.0 or 0.1.0? Start at 0.x while the API is still moving — semver explicitly treats 0.x as unstable, so you can break things between minor versions without penalty. Cut 1.0.0 when you are ready to commit to the contract, because from that point a breaking change forces a major bump.

How do I publish a beta without affecting users on the stable version? Bump to a prerelease version such as 3.0.0-beta.0 and publish with npm publish --tag next. Because the prerelease sorts below 3.0.0 and lives under a non-latest dist-tag, ^2.0.0 ranges and bare npm install never resolve to it; only npm install pkg@next opts in.

What is the difference between changesets and semantic-release? Changesets uses explicit intent files committed with each change and batches them into a reviewable release pull request, which fits monorepos with per-package versions. semantic-release infers the version directly from Conventional Commit messages and publishes fully unattended, which fits single-package repos that already enforce a commit convention.

Why does my caret range not pick up a new prerelease? By design. Prerelease versions are excluded from range matching unless the range itself names a prerelease (e.g. ^3.0.0-0). This prevents a ^2.0.0 consumer from being silently dragged onto an unstable 3.0.0-beta build.

Is a type-only change breaking?

It can be. Tightening a parameter type, narrowing a return, or making an optional field required breaks TypeScript consumers whose build will no longer compile, even if the runtime is unchanged. Treat the type surface as part of the public API and bump accordingly.

How do I ship a risky major without breaking everyone?

Publish it under a prerelease dist-tag like beta or next, so only consumers who opt in receive it. Gather real-world feedback while stable consumers stay untouched, then promote it to latest with npm dist-tag add once you are confident.

semantic-release or Changesets?

semantic-release infers bumps from conventional commits — great for a single package with commit discipline. Changesets uses explicit intent files — better for monorepos with curated changelogs. Pick one and enforce it in CI so the version is always derived, never typed by hand.

How does automated versioning handle a monorepo?

It computes each package's next version from the changes that touched it, then walks the dependency graph to bump packages that must move because a dependency did. That per-package computation is what makes independent versioning tractable, so a consumer of one package is not forced to upgrade for an unrelated sibling.

How do I stop a release from reusing an existing version?

Automate the bump so the version is computed from the changes rather than typed, and add a version-collision check in CI. With the version derived, a publish structurally cannot reuse a number, and the check catches any edge case before publish.

Should I automate versioning from day one?

For anything published regularly, yes — automating the bump (from conventional commits or changesets) removes the manual step where a version can be reused or mis-sized, and it produces an accurate changelog. Enforce the chosen model in CI so the version always derives from the changes.

How do I ship a breaking change safely?

Bump the major version, and stage the rollout with a prerelease dist-tag (next or beta) so opt-in consumers can validate it before it becomes the default. Promote it to latest once proven, and document the breaking change in the changelog so consumers know what to migrate.

Related

Package Publishing & Release Engineering