Automated Semantic Versioning with Conventional Commits and release-please
The Pipeline First
Here's the GitHub Actions workflow that handles everything. Versioning, changelog, GitHub release, npm publish. Zero manual steps.
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
token: ${{ secrets.GITHUB_TOKEN }}
publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm test
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}
ghcr.io/${{ github.repository }}:latest
Merge to main. release-please opens a PR with the version bump and changelog. Merge that PR. Release, npm publish, Docker image. Done.
Conventional Commits: The Contract
Automated versioning needs structure. Conventional commits give you that.
feat: add webhook retry logic
fix: handle null response from payment API
docs: update API authentication guide
chore: upgrade eslint to v9
perf: cache database connection pool
feat!: redesign authentication flow
BREAKING CHANGE: JWT tokens now use RS256 instead of HS256.
The format is simple: type(optional-scope): description.
The rules:
fix:bumps PATCH (1.0.0 -> 1.0.1)feat:bumps MINOR (1.0.0 -> 1.1.0)feat!:orBREAKING CHANGE:bumps MAJOR (1.0.0 -> 2.0.0)- Everything else (
docs,chore,test,ci) — no version bump
That's it. Your commit messages drive your version numbers. No debates in Slack about whether this is a minor or patch release.
Enforcing Commit Format
Conventions are worthless without enforcement. Use commitlint.
npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'chore', 'ci', 'build', 'revert'
]],
'subject-max-length': [2, 'always', 100],
'body-max-line-length': [2, 'always', 200],
}
};
Hook it into your git workflow with Husky:
npm install --save-dev husky
npx husky init
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg
Now bad commits fail locally. Fast feedback. No waiting for CI to tell you your commit message is wrong.
For CI, add this job:
lint-commits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: commitlint.config.js
Every PR gets checked. Non-conventional commits block the merge. No exceptions.
How release-please Works
release-please reads your commit history since the last release. It groups commits by type and calculates the next version.
When you push to main, it either:
- Creates a release PR with the bumped version and generated changelog
- Updates an existing release PR if one is already open
The release PR looks like this:
## [2.3.0](https://github.com/org/repo/compare/v2.2.1...v2.3.0) (2026-03-20)
### Features
* add webhook retry logic (#142)
* support bulk user import via CSV (#138)
### Bug Fixes
* handle null response from payment API (#141)
* fix race condition in queue consumer (#139)
### Performance Improvements
* cache database connection pool (#140)
Merge that PR. release-please creates the GitHub release, tags the commit, and your publish job fires.
Configuration: release-please-config.json
For monorepos or custom behavior, add a config file:
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "node",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"include-component-in-tag": false,
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "deps", "section": "Dependencies" },
{ "type": "docs", "section": "Documentation", "hidden": true },
{ "type": "chore", "section": "Miscellaneous", "hidden": true }
]
}
bump-minor-pre-major keeps you on 0.x during early development. Features bump patch, not minor, until you hit 1.0.0. Smart default for new projects.
Hide docs and chore from changelogs. Nobody reads those in release notes.
Monorepo Setup
Multiple packages, independent versioning. release-please handles it.
{
"packages": {
"packages/api": {
"release-type": "node",
"component": "api"
},
"packages/web": {
"release-type": "node",
"component": "web"
},
"packages/shared": {
"release-type": "node",
"component": "shared"
}
}
}
Commits scoped to a package only bump that package:
feat(api): add rate limiting middleware
fix(web): correct dark mode toggle state
Each package gets its own release PR, version, and changelog. Independent release cycles. No more "bump everything because the API changed."
GitLab CI Alternative
Not on GitHub? Here's the GitLab equivalent using semantic-release:
release:
stage: deploy
image: node:20
before_script:
- npm install -g semantic-release @semantic-release/gitlab
@semantic-release/changelog @semantic-release/git
script:
- semantic-release
rules:
- if: $CI_COMMIT_BRANCH == "main"
// .releaserc.json
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
["@semantic-release/npm", { "npmPublish": true }],
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
}],
"@semantic-release/gitlab"
]
}
Same concept, different tool. Commits drive versions. Automation does the rest.
Pre-1.0 Strategy
Starting at v1.0.0 on day one is wrong. Use 0.x.y during early development when the API is still unstable.
0.1.0 — First feature release
0.2.0 — Second feature release (breaking changes okay)
0.15.3 — Still iterating, still pre-stable
1.0.0 — Public API is stable, breaking changes require a major bump
release-please handles this with bump-minor-pre-major:
{
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true
}
During 0.x, feat: commits bump patch (0.1.0 -> 0.1.1) and feat!: bumps minor (0.1.0 -> 0.2.0). This prevents you from reaching v47.0.0 before your first stable release.
Graduate to 1.0.0 deliberately. It's a commitment to backwards compatibility.
Handling Breaking Changes Gracefully
Breaking changes are inevitable. Communicate them clearly.
feat!: redesign authentication flow
BREAKING CHANGE: JWT tokens now use RS256 instead of HS256.
All existing tokens are invalidated. Clients must re-authenticate.
Migration steps:
1. Update the client SDK to v2.x
2. Regenerate service account tokens
3. Deploy the new auth service before updating clients
The BREAKING CHANGE footer appears in the generated changelog. Users see exactly what changed and how to migrate. No guessing.
Deprecation Before Removal
For libraries and APIs, deprecate before removing:
v1.5.0 — feat: add new auth method, deprecate old method
v1.6.0 — docs: document migration from old to new auth
v2.0.0 — feat!: remove deprecated auth method
Give users at least one minor version cycle to migrate. release-please tracks the deprecation notices in changelogs automatically.
Docker Image Tagging with Semver
Semantic versioning extends beyond npm packages. Apply it to container images:
# In the release workflow, after release-please creates the tag
docker:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}},value=${{ needs.release-please.outputs.version }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
A release of v2.3.1 produces three tags: 2.3.1, 2.3, and 2. Users pinning to 2 get all compatible updates. Users pinning to 2.3.1 get exactly that version.
Troubleshooting
Problem: release-please isn't creating a release PR.
Fix: Check that commits use conventional format. Run git log --oneline and verify prefixes. Non-conventional commits are invisible to release-please.
Problem: Version bumps are wrong (patch instead of minor).
Fix: Ensure you're using feat: for features and fix: for fixes. A common mistake is writing update: add new endpoint — release-please doesn't recognize update: as a type.
Problem: Changelog is too noisy with chore/docs commits.
Fix: Configure changelog-sections to hide non-user-facing changes:
{
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "chore", "hidden": true },
{ "type": "docs", "hidden": true },
{ "type": "test", "hidden": true }
]
}
Problem: Monorepo packages version independently but you want lockstep.
Fix: Use linked-versions in release-please to group packages:
{
"group-pull-request-title-pattern": "chore: release ${component}",
"linked-versions": {
"group1": {
"packages": ["packages/api", "packages/web"],
"version": "1.0.0"
}
}
}
The Result
Before: someone decides it's release day. They manually bump package.json, write changelog entries from memory, tag the commit, create a GitHub release, copy-paste notes, publish to npm. Takes 30 minutes. Gets skipped when people are busy.
After: merge to main. Everything happens. Every time. Version numbers communicate meaning. Changelogs write themselves. Docker images get proper tags. Monorepo packages release independently.
If it's not automated, it doesn't exist.
Best Practices Checklist
- Use conventional commits from day one — retrofitting is painful
- Enforce commit format with commitlint in both local hooks and CI
- Start at
0.1.0and graduate to1.0.0deliberately - Hide
chore,docs, andtestcommits from changelogs - Use
linked-versionsin monorepos when packages must release together - Tag Docker images with semver, not just commit SHAs
- Document breaking changes with migration steps in the commit body
- Run
release-pleaseonly on main — feature branches don't need releases
Version your software like you mean it.
Related Articles
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines
Eliminate duplicated CI/CD logic across repositories using GitHub Actions reusable workflows and composite actions with real-world examples.
Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions
Harden GitHub Actions security with least-privilege permissions, OIDC federation, SHA-pinned actions, and secrets management best practices.