Skip to content
CI/CD

Semantic Versioning and Automated Releases with Conventional Commits

Version numbers should encode compatibility, not vibes. Learn semantic versioning, the Conventional Commits spec, commitlint enforcement, and fully automated releases with semantic-release and Release Please.

A
Abhishek Patel9 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Semantic Versioning and Automated Releases with Conventional Commits
Semantic Versioning and Automated Releases with Conventional Commits

How Version Numbers Got Rules: A Short History

Before 2010, version numbers meant whatever the project maintainer wanted. Linux was on 2.6.32 for years. Firefox jumped from 3.6 to 4.0 because someone decided it felt like a major release. Node.js sat at 0.10 long after being production-ready. "2.0" meant "we rewrote the website" as often as it meant "we broke the API." Consumers who depended on a library had no structural way to know whether upgrading from 1.4.2 to 1.5.0 was safe -- they had to read the changelog, and hope the author had bothered to write one.

  • 2010: Tom Preston-Werner (a GitHub co-founder) publishes semver.org, formalizing MAJOR.MINOR.PATCH as a contract between library authors and consumers. npm adopts it immediately because its dependency resolver needs structural meaning in version numbers to compute safe upgrades.
  • 2013: The Angular team ships the commit convention that becomes Conventional Commits, using commit types (feat/fix/docs) to drive changelog generation and version bumps automatically from git history.
  • 2015: semantic-release lands on GitHub. Every push to main becomes a potential release. Humans no longer decide version numbers -- commit messages do.
  • 2017: The Conventional Commits spec is formalized at conventionalcommits.org, pulling the various dialects into one shared format.
  • 2021: Google releases Release Please, offering a middle path -- automated version calculation, but a human-reviewed Release PR before anything ships.
  • 2026: The combination of semantic versioning + Conventional Commits + automated release tooling is the default for any library with external consumers. Version numbers mean something. Releases are boring. That is the win.

This guide is the working engineer's walkthrough of that stack. We will cover what each number in MAJOR.MINOR.PATCH actually signals, how Conventional Commits map to version bumps, how to enforce the format so one rogue commit does not tank automation, and when to pick semantic-release vs Release Please. If you are still picking versions by gut feel in 2026, this is how you stop.

The Three Numbers Explained

Given a version 2.4.1:

  • MAJOR (2) -- incremented when you make incompatible API changes. Consumers must update their code to upgrade. Examples: removing a public method, changing a function's return type, renaming a configuration key.
  • MINOR (4) -- incremented when you add functionality in a backward-compatible way. Consumers can upgrade without code changes. Examples: adding a new endpoint, adding an optional parameter, introducing a new feature.
  • PATCH (1) -- incremented when you make backward-compatible bug fixes. Consumers should always upgrade. Examples: fixing a crash, correcting a calculation, patching a security vulnerability.

Pre-release and Build Metadata

Semver supports two additional identifiers:

1.0.0-alpha.1        # pre-release: unstable, may change
1.0.0-beta.3         # pre-release: feature-complete, testing
1.0.0-rc.1           # pre-release: release candidate
1.0.0+build.123      # build metadata: ignored in precedence
1.0.0-alpha.1+001    # both pre-release and build metadata

Pre-release versions have lower precedence than the release version: 1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0. Build metadata is ignored when determining version precedence.

What Are Conventional Commits?

Definition: Conventional Commits is a specification for writing structured commit messages in the format type(scope): description. The commit type (feat, fix, etc.) and optional breaking change indicator map directly to semver bumps, enabling automated version determination from git history.

The Commit Message Format

type(scope): description

[optional body]

[optional footer(s)]

Commit Types and Their Semver Impact

TypeDescriptionSemver Bump
featNew featureMINOR
fixBug fixPATCH
docsDocumentation onlyNone (or PATCH)
styleFormatting, whitespaceNone
refactorCode change that neither fixes nor addsNone (or PATCH)
perfPerformance improvementPATCH
testAdding or fixing testsNone
choreBuild process, dependenciesNone (or PATCH)
BREAKING CHANGEIn footer, or ! after typeMAJOR

Real Examples

# PATCH bump
fix(auth): prevent token refresh race condition

# MINOR bump
feat(api): add pagination support to /users endpoint

# MAJOR bump (using ! notation)
feat(api)!: change /users response from array to paginated object

# MAJOR bump (using footer)
feat(api): change /users response format

BREAKING CHANGE: /users now returns { data: [], meta: { page, total } }
instead of a plain array. All consumers must update their response parsing.

Enforcing Commit Messages With commitlint

Conventional Commits only work if every commit follows the format. commitlint validates commit messages against the spec and rejects non-conforming ones.

Step-by-Step Setup

  1. Install commitlint and the conventional config:
    npm install --save-dev @commitlint/cli @commitlint/config-conventional
  2. Create the config file:
    // commitlint.config.js
    module.exports = {
      extends: ['@commitlint/config-conventional'],
      rules: {
        'type-enum': [
          2,
          'always',
          ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci', 'revert'],
        ],
        'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
        'header-max-length': [2, 'always', 100],
      },
    };
  3. Add a git hook with Husky:
    npm install --save-dev husky
    npx husky init
    echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

Now every commit is validated locally before it's created. Pair this with a CI check that validates PR commit messages to catch anything that bypasses the local hook.

# .github/workflows/commitlint.yml
name: Lint Commits
on: [pull_request]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}

Watch out: If you're using squash merges on GitHub, the individual commit messages don't matter -- only the squash commit message does. Configure your repository to use the PR title as the squash commit message, and enforce Conventional Commits format on PR titles instead of individual commits.

Automated Releases With semantic-release

semantic-release reads your commit history since the last release, determines the next version based on Conventional Commits types, generates a changelog, creates a git tag, and publishes to npm -- all automatically.

// .releaserc.json
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    ["@semantic-release/npm", { "npmPublish": true }],
    ["@semantic-release/github", { "successComment": false }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]"
    }]
  ]
}
# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

permissions:
  contents: write
  issues: write
  pull-requests: write
  id-token: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Release Please: Google's Alternative

Release Please takes a different approach than semantic-release. Instead of releasing immediately on merge, it creates a "Release PR" that accumulates changes and lets you control when the release happens.

# .github/workflows/release-please.yml
name: Release Please
on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node

When you merge commits to main, Release Please updates or creates a PR with version bumps and a changelog. When you're ready to release, merge the Release PR. This gives you a review step before the release goes out -- useful for teams that want automated version calculation but manual release timing.

Featuresemantic-releaseRelease Please
Release triggerAutomatic on merge to mainMerge the Release PR
ChangelogGenerated per releaseAccumulated in Release PR
Review stepNone (fully automated)Review the Release PR
npm publishBuilt-in pluginSeparate step after release
Monorepo supportVia multi-semantic-releaseNative (manifest config)
Best forLibraries with frequent releasesApplications, teams wanting release control

Monorepo Versioning

Monorepos complicate versioning because packages have different release cadences and interdependencies. Two approaches work well:

Independent Versioning

Each package has its own version. A commit scoped to feat(api): ... bumps only the API package. This is the standard for monorepos publishing multiple npm packages.

// release-please-config.json
{
  "packages": {
    "packages/core": { "release-type": "node" },
    "packages/cli": { "release-type": "node" },
    "packages/utils": { "release-type": "node" }
  }
}

Synchronized Versioning

All packages share the same version number. Any change bumps everything. Simpler but wasteful -- a typo fix in one package bumps the version of all packages. Works best when packages are always deployed together.

// release-please-config.json (linked versions)
{
  "packages": {
    "packages/core": {},
    "packages/cli": {},
    "packages/utils": {}
  },
  "group-name": "my-project",
  "linked-packages": ["packages/core", "packages/cli", "packages/utils"]
}

Pro tip: For monorepos with internal packages that aren't published to npm, skip independent versioning entirely. Use workspace protocol references and deploy all services from HEAD. Reserve semver and release automation for packages that external consumers depend on.

Pre-release Versions for Testing

Pre-release versions let you publish unstable versions for testing without affecting the latest stable release:

// .releaserc.json with pre-release branches
{
  "branches": [
    "main",
    { "name": "beta", "prerelease": true },
    { "name": "alpha", "prerelease": true }
  ]
}

Commits to the beta branch produce versions like 2.1.0-beta.1, 2.1.0-beta.2. Commits to main produce stable releases. Consumers using ^2.0.0 in their package.json won't accidentally install a beta -- they'd need to explicitly install @beta.

Automation Failure Modes I Have Personally Caused

The Missing 1.x.0 That Became 10.0.0

A library I maintain had a typo fix scoped as fix!: correct typo in README. The ! on a docs-only change flagged a breaking change. semantic-release published 10.0.0 of a library that had no breaking API changes. The fix: audit every ! before merging, and never use the ! shortcut for anything that is not strictly an API contract change.

Dependabot Squashing Breaks Automation

Dependabot creates commits like chore(deps): bump axios from 1.2.3 to 1.2.4. When GitHub's squash-merge uses the PR title as the squash message, you get a commit with type chore -- no release triggered. Weeks of dependency updates accumulate with no version bumps. Fix: configure Dependabot to use fix(deps) for patch bumps, or add a release-please-manifest hook that treats dependency updates as patch releases.

Monorepo PR Touching Two Packages

A single PR that modifies packages/core and packages/cli with one commit scoped to core will bump only the core package, leaving the CLI at an older version that silently depends on an unreleased core change. Fix: in a monorepo, enforce one commit per package (or use the scope of the commit to drive which packages get bumped, and make the rule explicit).

Git Tag Drift Between CI Nodes

If your release job runs on a CI worker that shallow-clones the repo (fetch-depth: 1), semantic-release cannot see previous tags and tries to re-release from scratch. It either fails with a confusing error or publishes the wrong version. Always use fetch-depth: 0 on the release workflow.

Frequently Asked Questions

What is semantic versioning?

Semantic versioning (semver) is a versioning convention using the format MAJOR.MINOR.PATCH. MAJOR increments signal breaking changes that require consumer code updates. MINOR increments signal new backward-compatible features. PATCH increments signal backward-compatible bug fixes. The version number communicates upgrade risk so consumers can make informed decisions.

What are Conventional Commits?

Conventional Commits is a specification for structuring commit messages in the format type(scope): description. The type (feat, fix, docs, etc.) maps to a semver bump level. A feat commit triggers a MINOR bump, a fix triggers a PATCH bump, and a BREAKING CHANGE footer or ! after the type triggers a MAJOR bump. This enables automated version determination from commit history.

What is the difference between semantic-release and Release Please?

Semantic-release publishes automatically on every merge to main -- fully hands-off. Release Please creates a "Release PR" that accumulates changes, letting you choose when to release by merging the PR. semantic-release is better for libraries with frequent releases. Release Please suits teams wanting automated version calculation but manual release timing and a review step.

How do I handle breaking changes in semver?

Increment the MAJOR version. In Conventional Commits, add a BREAKING CHANGE: footer to your commit message, or append ! after the type (e.g., feat(api)!: description). Both semantic-release and Release Please detect these markers and bump MAJOR accordingly. Always document what changed and how consumers should migrate in the commit body.

Should I use semver for applications or only libraries?

Semver is most valuable for libraries and APIs where consumers need to assess upgrade risk. For applications (web apps, services) that aren't consumed as dependencies, semver is still useful for tracking releases and generating changelogs, but the MAJOR/MINOR/PATCH distinction matters less since there are no external consumers pinning version ranges.

How do I enforce Conventional Commits across a team?

Three layers: local git hooks via Husky and commitlint (catches issues immediately), CI validation on pull requests (catches anything that bypasses hooks), and PR title linting if using squash merges (since the squash commit message comes from the PR title). Start with CI validation -- it's the hardest to bypass and requires no local setup from developers.

What happens if someone forgets to use Conventional Commits?

If commitlint is configured, the commit is rejected locally or in CI. If commitlint isn't enforced, semantic-release and Release Please will skip non-conforming commits -- they won't trigger any version bump. This is safe but means changes go unreleased. The fix is retroactive: create a new commit with the correct Conventional Commit message that references the missed change.

Conclusion

Semantic versioning gives version numbers meaning. Conventional Commits automate the decision of what that version should be. Together with semantic-release or Release Please, you get a fully automated pipeline: write a commit message, merge to main, and the tools handle version bumps, changelogs, git tags, and npm publishing.

Start with commitlint and Husky to enforce the commit format. Add Release Please for automated version management with a review step. Once the team is comfortable with the workflow, consider semantic-release for fully hands-off releases. The investment is a 30-minute setup and slightly more thoughtful commit messages -- the return is zero ambiguity about what shipped and when.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Related Articles

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.