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