Semantic Versioning: Strategy and Automation
A practical guide to semantic versioning covering version strategy, Conventional Commits, automated changelog generation, and CI/CD release automation with npm.
Semantic Versioning: Strategy and Automation
Overview
Version numbers are a contract. Every time you publish a package, the version you stamp on it tells every consumer whether they can safely upgrade or whether they need to budget time for migration work. Get it wrong and you break builds across the ecosystem. Get it right and people trust your releases, adopt updates faster, and stop pinning exact versions out of fear.
I have maintained npm packages consumed by hundreds of projects, and I have watched teams treat versioning as an afterthought until a minor bump ships a breaking change that cascades through a dependency tree at 2 AM on a Friday. Semantic versioning is not just a numbering scheme. It is a communication protocol between maintainers and consumers. This article covers the specification itself, the tooling that automates it, and the release workflows that keep it honest.
The Semantic Versioning Specification
Semver uses a three-part number: MAJOR.MINOR.PATCH. The rules are simple and non-negotiable.
- MAJOR increments when you make incompatible API changes. Removing a function, renaming a parameter, changing return types, dropping support for a Node.js version — anything that could break existing consumer code.
- MINOR increments when you add functionality in a backward-compatible manner. New functions, new optional parameters, new events. Existing code continues to work without changes.
- PATCH increments when you make backward-compatible bug fixes. No new features, no API surface changes. Just corrections to existing behavior.
The version 0.x.y is special. During initial development (major version zero), the public API is not considered stable. Anything can change at any time. This is why so many packages sit at 0.x for years — the maintainers are signaling that they reserve the right to break things between minors.
Pre-release Versions
Semver supports pre-release identifiers appended with a hyphen: 1.0.0-alpha.1, 2.3.0-beta.4, 3.0.0-rc.1. These indicate unstable releases that may not satisfy the normal version range expectations. A pre-release version has lower precedence than the associated normal version, so 1.0.0-alpha.1 < 1.0.0.
The conventional progression is:
1.0.0-alpha.1 # Internal testing, unstable
1.0.0-alpha.2 # Iterating on alpha feedback
1.0.0-beta.1 # Feature-complete, external testing
1.0.0-beta.2 # Bug fixes from beta feedback
1.0.0-rc.1 # Release candidate, final validation
1.0.0-rc.2 # Critical fix in release candidate
1.0.0 # Stable release
Build Metadata
Build metadata is appended with a plus sign: 1.0.0+20260213, 1.0.0+build.456. Build metadata is ignored for version precedence — 1.0.0+build.1 and 1.0.0+build.2 are considered equal. This is useful for embedding build numbers or commit hashes without affecting version ordering.
What Constitutes a Breaking Change
This is where most teams stumble. A breaking change is not just removing a function. Here is my working list after years of getting burned:
- Removing or renaming an exported function, class, or method
- Changing the number or order of required parameters
- Changing a return type (returning an object where you previously returned a string)
- Throwing an error where you previously returned null or undefined
- Dropping support for a Node.js version listed in your
enginesfield - Changing the default behavior of an existing option
- Removing a key from a returned object that consumers may destructure
- Changing a synchronous API to asynchronous (or vice versa)
- Upgrading a peer dependency to a new major version
Things that are not breaking changes but often get confused:
- Adding a new optional parameter with a default value
- Adding a new key to a returned object
- Adding a new exported function
- Fixing a bug (even if someone depended on the buggy behavior)
- Improving performance
The bug fix case is contentious. If your package returned the wrong value and someone worked around it, fixing the bug might break their workaround. The semver specification sides with correctness — bug fixes are patches. If someone depended on broken behavior, that is on them.
npm Version Ranges
Understanding how npm interprets version ranges is critical to knowing why your version numbers matter.
{
"dependencies": {
"exact": "2.1.3",
"patch-range": "~2.1.3",
"minor-range": "^2.1.3",
"greater-equal": ">=2.1.3",
"range": ">=2.1.3 <3.0.0",
"wildcard": "2.1.x"
}
}
"2.1.3"— Exact version. Only2.1.3will be installed. Use this when you cannot tolerate any variation."~2.1.3"— Tilde range. Allows patch-level changes:>=2.1.3 <2.2.0. Safe when you trust the maintainer's patch discipline."^2.1.3"— Caret range (npm default). Allows minor and patch changes:>=2.1.3 <3.0.0. This is whatnpm installwrites. It trusts that the maintainer will not ship breaking changes before the next major."2.1.x"— Equivalent to~2.1.0. Any patch within2.1.
The caret range is why your major version number matters so much. When you bump major, every consumer using ^ is frozen at the old version until they explicitly update their package.json. Bump major too often and you fracture your user base. Bump too rarely and you accumulate breaking changes that make the eventual major release painful.
The npm version Command
npm has a built-in command for bumping versions, creating git tags, and committing:
# Bump patch: 1.2.3 -> 1.2.4
npm version patch
# Bump minor: 1.2.3 -> 1.3.0
npm version minor
# Bump major: 1.2.3 -> 2.0.0
npm version major
# Pre-release bumps
npm version prepatch # 1.2.3 -> 1.2.4-0
npm version preminor # 1.2.3 -> 1.3.0-0
npm version premajor # 1.2.3 -> 2.0.0-0
npm version prerelease # 1.2.4-0 -> 1.2.4-1
# Pre-release with identifier
npm version prerelease --preid=beta # 1.2.3 -> 1.2.4-beta.0
# Set explicit version
npm version 3.0.0-rc.1
By default, npm version creates a git commit with the message v1.2.4 and a git tag v1.2.4. You can customize this:
# Custom commit message (%s is replaced with version)
npm version patch -m "release: %s"
# Skip git tag
npm version patch --no-git-tag-version
# Sign the git tag
npm version patch --sign-git-tag
npm Dist-Tags for Release Channels
Dist-tags let you publish multiple release streams from a single package. By default, npm publish publishes to the latest tag. You can create additional channels:
# Publish a beta release
npm version prerelease --preid=beta
npm publish --tag beta
# Publish a release candidate
npm version prerelease --preid=rc
npm publish --tag next
# Promote next to latest
npm dist-tag add [email protected] latest
# List current tags
npm dist-tag ls my-package
Consumers install from channels explicitly:
npm install my-package # Gets latest
npm install my-package@beta # Gets beta channel
npm install my-package@next # Gets next channel
This pattern is essential for testing major releases with early adopters before cutting over the latest tag.
Conventional Commits
Conventional Commits is a specification for structuring commit messages so that tooling can automatically determine version bumps and generate changelogs. The format is:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
The types that matter for versioning:
fix:— Maps to a PATCH bumpfeat:— Maps to a MINOR bumpBREAKING CHANGE:in the footer, or!after the type — Maps to a MAJOR bump
fix(parser): handle nested brackets in expressions
The regex parser failed to account for nested bracket pairs,
causing malformed output when input contained [[...]] patterns.
Closes #142
feat(api): add batch processing endpoint
Added POST /batch endpoint that accepts an array of operations
and processes them in a single transaction.
feat(auth)!: require API key for all endpoints
BREAKING CHANGE: All endpoints now require an x-api-key header.
Previously, read-only endpoints were unauthenticated. Consumers
must generate an API key in the dashboard before upgrading.
Other conventional types (chore, docs, style, refactor, perf, test, ci, build) do not trigger version bumps by default but appear in changelogs for transparency.
Complete Working Example: Automated Release Pipeline
Here is a full release workflow using Conventional Commits with commitlint for enforcement, standard-version for automated versioning and changelog generation, husky for commit message hooks, and a GitHub Actions pipeline for automated npm publishing.
Project Setup
mkdir my-library && cd my-library
npm init -y
npm install --save-dev @commitlint/cli @commitlint/config-conventional
npm install --save-dev standard-version
npm install --save-dev husky
Configure commitlint
Create commitlint.config.js in the project root:
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test'
]
],
'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
'header-max-length': [2, 'always', 100]
}
};
Configure Husky
Set up husky to enforce commit message format on every commit:
npx husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
This creates .husky/commit-msg:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"
Add the husky install step to package.json:
{
"scripts": {
"prepare": "husky install"
}
}
Now any commit that does not follow the Conventional Commits format is rejected before it enters your history.
Configure standard-version
Create .versionrc.json in the project root:
{
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "chore", "hidden": true },
{ "type": "style", "hidden": true },
{ "type": "test", "hidden": true },
{ "type": "ci", "hidden": true },
{ "type": "build", "hidden": true }
],
"commitUrlFormat": "https://github.com/your-org/my-library/commit/{{hash}}",
"compareUrlFormat": "https://github.com/your-org/my-library/compare/{{previousTag}}...{{currentTag}}",
"issueUrlFormat": "https://github.com/your-org/my-library/issues/{{id}}"
}
Add release scripts to package.json:
{
"scripts": {
"prepare": "husky install",
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major",
"release:patch": "standard-version --release-as patch",
"release:alpha": "standard-version --prerelease alpha",
"release:beta": "standard-version --prerelease beta",
"release:dry-run": "standard-version --dry-run"
}
}
The Release Workflow Script
Create a helper script that handles the full release flow:
// scripts/release.js
var execSync = require('child_process').execSync;
var fs = require('fs');
var path = require('path');
function run(cmd) {
console.log('> ' + cmd);
return execSync(cmd, { encoding: 'utf8', stdio: 'inherit' });
}
function getPackageVersion() {
var pkgPath = path.join(process.cwd(), 'package.json');
var pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return pkg.version;
}
function release() {
var args = process.argv.slice(2);
var dryRun = args.indexOf('--dry-run') !== -1;
var releaseType = null;
args.forEach(function(arg) {
if (['major', 'minor', 'patch'].indexOf(arg) !== -1) {
releaseType = arg;
}
});
// Ensure working directory is clean
try {
execSync('git diff --exit-code', { stdio: 'pipe' });
execSync('git diff --cached --exit-code', { stdio: 'pipe' });
} catch (e) {
console.error('Error: Working directory is not clean. Commit or stash changes first.');
process.exit(1);
}
// Ensure we are on main branch
var branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
if (branch !== 'main' && branch !== 'master') {
console.error('Error: Releases must be created from main or master branch.');
console.error('Current branch: ' + branch);
process.exit(1);
}
// Pull latest
run('git pull origin ' + branch);
// Run tests
console.log('\nRunning tests...');
run('npm test');
// Run standard-version
var versionBefore = getPackageVersion();
console.log('\nCurrent version: ' + versionBefore);
var svCmd = 'npx standard-version';
if (releaseType) {
svCmd += ' --release-as ' + releaseType;
}
if (dryRun) {
svCmd += ' --dry-run';
run(svCmd);
console.log('\nDry run complete. No changes were made.');
return;
}
run(svCmd);
var versionAfter = getPackageVersion();
console.log('\nVersion bumped: ' + versionBefore + ' -> ' + versionAfter);
// Push commit and tag
run('git push --follow-tags origin ' + branch);
console.log('\nRelease v' + versionAfter + ' pushed successfully.');
console.log('GitHub Actions will handle npm publishing.');
}
release();
GitHub Actions Pipeline
Create .github/workflows/release.yml:
name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Determine dist-tag
id: dist-tag
run: |
VERSION=$(node -p "require('./package.json').version")
if echo "$VERSION" | grep -q "alpha"; then
echo "tag=alpha" >> $GITHUB_OUTPUT
elif echo "$VERSION" | grep -q "beta"; then
echo "tag=beta" >> $GITHUB_OUTPUT
elif echo "$VERSION" | grep -q "rc"; then
echo "tag=next" >> $GITHUB_OUTPUT
else
echo "tag=latest" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
run: npm publish --tag ${{ steps.dist-tag.outputs.tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
generate_release_notes: true
Running a Release
The day-to-day workflow looks like this:
# Normal development — just use conventional commits
git add .
git commit -m "fix(parser): handle empty input gracefully"
git add .
git commit -m "feat(api): add request timeout configuration"
# When ready to release, do a dry run first
npm run release:dry-run
# Cut the release (standard-version reads commits, bumps version, updates CHANGELOG)
npm run release
# Or force a specific bump type
npm run release:major
# Or use the release script for full validation
node scripts/release.js
node scripts/release.js major
node scripts/release.js --dry-run
standard-version reads the commits since the last tag, determines the appropriate bump (the highest-impact commit wins — one feat!: means major regardless of how many fix: commits exist), updates package.json, generates CHANGELOG.md, creates a git commit, and creates a git tag. The push triggers the GitHub Actions pipeline, which runs tests across Node versions, publishes to npm with the correct dist-tag, and creates a GitHub Release.
Changelog Generation
standard-version generates a CHANGELOG.md that groups changes by type:
# Changelog
## [2.1.0](https://github.com/your-org/my-library/compare/v2.0.3...v2.1.0) (2026-02-13)
### Features
* **api:** add request timeout configuration ([a1b2c3d](https://github.com/your-org/my-library/commit/a1b2c3d))
* **cli:** support --json output flag ([d4e5f6a](https://github.com/your-org/my-library/commit/d4e5f6a))
### Bug Fixes
* **parser:** handle empty input gracefully ([b7c8d9e](https://github.com/your-org/my-library/commit/b7c8d9e))
* **auth:** refresh token before expiry ([f0a1b2c](https://github.com/your-org/my-library/commit/f0a1b2c))
### Performance
* **cache:** reduce memory usage by 40% with LRU eviction ([c3d4e5f](https://github.com/your-org/my-library/commit/c3d4e5f))
This is why structured commit messages matter. Without them, your changelog is a list of commit hashes that nobody reads.
Monorepo Versioning Strategies
Monorepos introduce a versioning decision that single-package repos do not have: do all packages share one version, or does each package version independently?
Fixed/locked versioning (used by Babel, Angular): every package gets the same version number, bumped together. Simple to reason about, but a breaking change in one package forces a major bump across all packages, even those that did not change.
Independent versioning (used by many Lerna-based monorepos): each package has its own version tracked independently. More accurate signaling, but harder to manage and can confuse consumers who need to know which versions of your packages are compatible.
Tools like Lerna, Changesets, and Nx handle monorepo versioning. Changesets is my current recommendation for new projects. It uses a pull-request-based workflow where contributors describe their changes in markdown files that are consumed at release time to determine bumps.
# Developer adds a changeset during PR
npx changeset
# CI consumes changesets and creates a "Version Packages" PR
npx changeset version
# CI publishes after the version PR is merged
npx changeset publish
Version Policies for Internal Packages
For packages consumed only within your organization, you have more flexibility. Some teams use a simplified policy:
- 0.x for experimental packages — Signal that the API is in flux and consumers should expect churn.
- 1.x+ for production packages — Follow strict semver.
- Major bumps require an RFC — Forces the team to document migration paths before shipping breaking changes.
- Lock internal packages to exact versions — Eliminates range resolution surprises in CI.
CalVer vs SemVer
Calendar versioning (CalVer) uses dates instead of semantic meaning: 2026.02.13, 2026.2.1, 26.2. Ubuntu, pip, and some infrastructure tools use CalVer. It communicates when a release was made but says nothing about compatibility.
My recommendation: use semver for libraries and packages. Use CalVer (or even just increment a build number) for applications and services that are not consumed as dependencies. An API service deployed behind a load balancer does not have the same compatibility contract as an npm package. Its version number is for internal tracking, not for dependency resolution.
API Versioning vs Package Versioning
These are different concerns that often get conflated. Package versioning (semver) governs the npm dependency graph. API versioning governs the HTTP contract between a service and its clients.
An npm package at version 3.2.1 might expose an API client that talks to API v2. The package version and the API version evolve independently. Bumping the API from v2 to v3 is a breaking change in the package (consumers need to update their API calls), which warrants a package major bump. But the package might go through dozens of minor and patch releases while the API version stays stable.
Common Issues and Troubleshooting
1. npm publish Rejects a Version That Already Exists
npm does not allow overwriting a published version. If you published 1.2.3 and realize it has a bug, you cannot republish 1.2.3. You must publish 1.2.4. If you unpublish within 72 hours, the version number is still reserved for 24 hours. Plan your releases carefully and use --dry-run liberally.
# Always dry-run first
npm publish --dry-run
# If you need to unpublish (within 72 hours)
npm unpublish [email protected]
2. Pre-release Versions Not Installing by Default
Consumers running npm install my-package will never get a pre-release version, even if 1.0.0-beta.5 is newer than 0.9.0. Pre-release versions require an explicit tag or version:
npm install my-package@beta
npm install [email protected]
This is by design. Do not file a bug.
3. standard-version Picks the Wrong Bump Type
If standard-version bumps a patch when you expected a minor, check your commit messages. A commit like feat - add new endpoint does not follow Conventional Commits because it uses a dash instead of a colon. The format must be exactly feat: description or feat(scope): description. Use commitlint to catch these before they enter your history.
# Check what standard-version will do without changing anything
npx standard-version --dry-run
4. Git Tags Out of Sync with npm Versions
This happens when someone manually edits package.json without creating a matching git tag, or when a CI pipeline fails halfway through a release. The fix is to reconcile manually:
# Check current npm version
npm view my-package version
# Check git tags
git tag --list 'v*' --sort=-v:refactorname
# Create a missing tag
git tag v1.2.4
git push origin v1.2.4
# Delete an erroneous tag
git tag -d v1.2.4
git push origin :refs/tags/v1.2.4
5. Changelog Contains Commits from Before the Last Release
This usually means the previous release tag is missing or malformed. standard-version looks for the most recent tag matching the configured format to determine the range of commits to include. If the tag is missing, it falls back to the beginning of the repository history. Verify your tags with git tag -l and ensure they match the expected pattern (usually v1.2.3).
Best Practices
Start at 1.0.0 sooner than you think. Staying at 0.x tells consumers your API is unstable. If people are using your package in production, you owe them stable versioning. Ship 1.0.0 and commit to the semver contract.
Use Conventional Commits from day one. Even if you are not automating releases yet, structured commit messages are valuable for archaeology. When you eventually set up automation, your entire history is already formatted correctly.
Run
--dry-runbefore every release. Bothnpm publish --dry-runandstandard-version --dry-runare cheap insurance. I have caught wrong bump types, missing files in the publish, and forgotten changelog entries with dry runs.Batch breaking changes into a single major release. If you have three breaking changes planned, ship them together in one major bump instead of three separate majors in a month. Your consumers will thank you for only having to migrate once.
Write migration guides for every major release. A
BREAKING CHANGESsection in the changelog is not enough. Publish a dedicated migration guide that walks consumers through every change with before/after code examples. Put it in aMIGRATION.mdfile or a dedicated documentation page.Use dist-tags for release channels. Never publish pre-release versions to the
latesttag. Usebeta,next,canary, or whatever naming makes sense for your project. Thelatesttag should always point to the most recent stable release.Pin your CI dependencies. Your release pipeline should use
npm ci(notnpm install) and your lockfile should be committed. A flaky transitive dependency update during a release build is a nightmare to debug.Communicate deprecation before removal. When planning to remove an API, deprecate it in a minor release first. Log a warning when the deprecated path is used. Then remove it in the next major. Give consumers at least one release cycle to migrate.
References
- Semantic Versioning 2.0.0 Specification — The canonical specification
- Conventional Commits — The commit message format specification
- npm semver calculator — Interactive tool for testing version ranges
- standard-version — Automated versioning and changelog generation
- commitlint — Lint commit messages against Conventional Commits
- Changesets — Version management for monorepos
- npm dist-tag documentation — Managing distribution tags
- CalVer — Calendar versioning specification