Web Development · Developer Experience
Conventional Commits and Automated Releases: The Setup That Pays for Itself
Standardized commit messages unlock automatic changelogs, version bumps, and release notes. Here's the full setup — from writing commits to shipping releases without manual steps.
Anurag Verma
5 min read
Sponsored
Every engineering team has a commit history that looks like this: “fix”, “wip”, “update”, “fix again”, “final”, “final2”, “ok this time”. The team ships a release, manually writes the changelog (“we fixed some bugs and added some features”), and bumps the version number by hand.
This is fixable. Not by persuading developers to write better commit messages through culture change or documentation, but by giving them a structured format that tools can parse.
Conventional Commits is that format. It’s a specification that maps commit types to semantic version bumps and changelog sections. Combined with a release tool, it removes the manual steps from the release process entirely.
The Specification
The format is:
<type>(<optional scope>): <description>
[optional body]
[optional footer(s)]
The types that matter for releases:
| Type | Changelog section | Version bump |
|---|---|---|
feat | Features | Minor (1.x.0) |
fix | Bug Fixes | Patch (1.0.x) |
BREAKING CHANGE (footer) | Breaking Changes | Major (x.0.0) |
docs, chore, test, refactor | Not included | No bump |
perf | Performance Improvements | Patch |
Real examples:
feat(auth): add Google OAuth login
fix(api): return 404 instead of 500 for missing resources
Previously the endpoint was crashing on missing IDs. Added explicit
null check before database query.
feat(api)!: require authentication on all endpoints
BREAKING CHANGE: unauthenticated requests will now receive 401.
Applications that called public endpoints without a token need to
update their requests.
The ! after the scope is shorthand for breaking change. Either the ! syntax or the BREAKING CHANGE footer will trigger a major version bump.
Setting Up Commitlint
Commitlint enforces the format at commit time using a git hook. Nobody ships a non-conforming commit accidentally.
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
// commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
rules: {
// Optionally enforce allowed scopes
'scope-enum': [2, 'always', ['api', 'auth', 'ui', 'docs', 'deps']],
// Keep subject lines short
'subject-max-length': [2, 'always', 72],
}
};
# .husky/commit-msg
npx --no -- commitlint --edit $1
Now every commit attempt runs the linter. An invalid commit message fails immediately with a clear error:
⧗ input: update stuff
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
If your team uses VS Code, the Conventional Commits extension (author: vivaxy) adds a commit message builder to the source control panel. This makes it much easier for developers who aren’t used to the format.
Automated Releases With semantic-release
semantic-release reads your git history, determines the next version based on commit types, generates a changelog, creates a git tag, publishes to npm (if applicable), and creates a GitHub release. All automatically, in CI.
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git
// .releaserc
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
["@semantic-release/npm", {
"npmPublish": false
}],
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
"@semantic-release/github"
]
}
The GitHub Actions workflow:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # semantic-release needs the full history
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # if publishing to npm
run: npx semantic-release
On every push to main, semantic-release analyzes commits since the last release. If it finds releasable commits (feat, fix, perf, breaking changes), it creates a release. If all commits since last release are chore, docs, or test, nothing happens. No release noise.
The generated changelog looks like this:
## [2.4.0](https://github.com/org/repo/compare/v2.3.1...v2.4.0) (2026-05-31)
### Features
* **auth:** add Google OAuth login ([abc1234](https://github.com/org/repo/commit/abc1234))
* **dashboard:** add export to CSV button ([def5678](https://github.com/org/repo/commit/def5678))
### Bug Fixes
* **api:** return 404 instead of 500 for missing resources ([ghi9012](https://github.com/org/repo/commit/ghi9012))
Every line is a link to the commit. The GitHub release notes are the same content, automatically posted.
For Teams That Don’t Publish to npm
If your project is an internal app rather than a library, you still get value from this setup. The changelog becomes your deployment log. GitHub releases become the record of what went out and when. You can tag specific versions for rollback purposes and see the exact commit range in any release.
Drop the @semantic-release/npm plugin and just keep the changelog and GitHub release plugins:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
["@semantic-release/git", {
"assets": ["CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
"@semantic-release/github"
]
}
Handling a Gradual Migration
If your repo has an existing history of non-conforming commits, the migration is easy: semantic-release only looks at commits since the last git tag. Tag your current state as v1.0.0 (or whatever makes sense), merge the commitlint hook, and everything going forward is enforced.
git tag v1.0.0
git push origin v1.0.0
For teams that resist message format requirements, the minimum viable version is just feat and fix. You can skip scope entirely. The two types give you automatic minor/patch bumps and a readable changelog.
The Payoff
The friction this removes is hard to appreciate until you’ve experienced it. In teams running this setup, “how do we write the release notes?” is no longer a question. The version bump discussion disappears. The changelog is always current. Developers get immediate feedback on commit message format when they’re at their keyboard, not when a reviewer notices it three days later.
The setup takes about two hours: install the packages, configure commitlint and husky, set up the GitHub Actions workflow, create the initial tag. After that, it runs automatically on every merge.
Sponsored
More from this category
More from Web Development
NestJS in 2026: The Enterprise Node.js Framework Most Teams Overlook
Test-Driven Development With AI Coding Assistants: Does TDD Still Make Sense in 2026?
WebGPU in 2026: What You Can Actually Build With GPU Compute in the Browser
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored