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.
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.
- MAJOR — incompatible API changes: removals, renames, stricter signatures, raised
enginesfloors, 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.
| 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.
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.
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.
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:
# .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. |
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.
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.
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.
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.
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
- Automating Releases with Changesets — intent-file-driven version bumps and release pull requests.
- Configuring Conventional Commits and semantic-release — commit-message-driven, fully unattended releases.
- npm Registry Publishing Workflows — what happens once the version is decided and the tarball is packed.
- Lockfile Management Strategies — how consumers resolve your published ranges into a locked tree.