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.
Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

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
| Type | Description | Semver Bump |
|---|---|---|
feat | New feature | MINOR |
fix | Bug fix | PATCH |
docs | Documentation only | None (or PATCH) |
style | Formatting, whitespace | None |
refactor | Code change that neither fixes nor adds | None (or PATCH) |
perf | Performance improvement | PATCH |
test | Adding or fixing tests | None |
chore | Build process, dependencies | None (or PATCH) |
BREAKING CHANGE | In footer, or ! after type | MAJOR |
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
- Install commitlint and the conventional config:
npm install --save-dev @commitlint/cli @commitlint/config-conventional - 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], }, }; - 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.
| Feature | semantic-release | Release Please |
|---|---|---|
| Release trigger | Automatic on merge to main | Merge the Release PR |
| Changelog | Generated per release | Accumulated in Release PR |
| Review step | None (fully automated) | Review the Release PR |
| npm publish | Built-in plugin | Separate step after release |
| Monorepo support | Via multi-semantic-release | Native (manifest config) |
| Best for | Libraries with frequent releases | Applications, 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.
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
Multi-Cluster Kubernetes: Argo CD ApplicationSet Patterns
When 10+ clusters or 50+ services break hand-written GitOps. ApplicationSet's four generators (cluster list, Git directory, PR, cluster decision), real production patterns (env promotion, per-tenant, multi-region failover, preview envs), and the sharp edges (template debugging, cascading mistakes, RBAC).
11 min read
AI/ML EngineeringLLM Latency: TTFT, ITL, and Why End-User Latency Isn't What You Think
LLM latency decomposes into TTFT (time to first token, 300-1500ms), ITL (inter-token, 10-30ms), and total time. Each has different causes and fixes. Why streaming dominates UX, when Cerebras/Groq beat Claude on speed, and the optimization playbook.
11 min read
DevOpsPython uv vs pip vs Poetry vs PDM: Speed Benchmarks 2026
Real benchmarks: uv installs Django + ML stack in 8s vs pip's 90s, Poetry's 50s, PDM's 38s. Why uv is fast (Rust + parallelism + PubGrub), what pip still does that uv doesn't, migration paths, and where Poetry's ergonomics still win.
12 min read
Enjoyed this article?
Get more like this in your inbox. No spam, unsubscribe anytime.