Tooling

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 engines field
  • 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. Only 2.1.3 will 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 what npm install writes. It trusts that the maintainer will not ship breaking changes before the next major.
  • "2.1.x" — Equivalent to ~2.1.0. Any patch within 2.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 bump
  • feat: — Maps to a MINOR bump
  • BREAKING 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

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

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

  3. Run --dry-run before every release. Both npm publish --dry-run and standard-version --dry-run are cheap insurance. I have caught wrong bump types, missing files in the publish, and forgotten changelog entries with dry runs.

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

  5. Write migration guides for every major release. A BREAKING CHANGES section 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 a MIGRATION.md file or a dedicated documentation page.

  6. Use dist-tags for release channels. Never publish pre-release versions to the latest tag. Use beta, next, canary, or whatever naming makes sense for your project. The latest tag should always point to the most recent stable release.

  7. Pin your CI dependencies. Your release pipeline should use npm ci (not npm install) and your lockfile should be committed. A flaky transitive dependency update during a release build is a nightmare to debug.

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

Powered by Contentful