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,…

Version Numbers Shouldn't Require a Meeting
Most teams pick version numbers by gut feel. Someone decides "this feels like a 2.0" or "just bump the patch." This works until a minor version ships a breaking change that takes down a consumer's production environment. Semantic versioning (semver) fixes this by encoding compatibility information directly in the version number. Pair it with Conventional Commits, and you can automate the entire process -- version bumps, changelogs, and releases happen without human judgment calls.
The result: every version number tells consumers exactly what changed and whether upgrading is safe. No guessing, no meetings, no "let me check the changelog."
What Is Semantic Versioning?
Definition: Semantic Versioning (semver) is a versioning scheme using the format MAJOR.MINOR.PATCH where MAJOR increments for breaking changes, MINOR for backward-compatible new features, and PATCH for backward-compatible bug fixes. It communicates compatibility intent so consumers can safely determine upgrade risk.
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.
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
GitHub Actions Deep Dive: Writing Reusable Workflows
Go beyond basic GitHub Actions: composite actions, reusable workflows, secret passing, dependency caching, matrix builds, and permission lockdown patterns you can apply immediately.
10 min read
CI/CDContainer Image Scanning: Catching Vulnerabilities Before They Ship
Container images carry hundreds of dependencies you didn't write. Learn how to scan them with Trivy, Grype, Snyk, and Docker Scout, manage false positives, choose minimal base images, and automate dependency updates.
10 min read
CI/CDBuilding a Monorepo CI Pipeline That Doesn't Fall Apart at Scale
Monorepo CI should only build what changed. Learn affected-service detection with git diff, Nx, Turborepo, and Bazel, plus remote caching, shared library versioning, and practical GitHub Actions configurations.
11 min read
Enjoyed this article?
Get more like this in your inbox. No spam, unsubscribe anytime.