Web Development · Developer Experience
Conventional Commits and Automated Releases
Structured commit messages unlock automatic changelogs, version bumps, and release notes. The full setup, from commitlint hooks to shipping in CI.
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.
Frequently asked questions
- Which commit types actually trigger a release?
- feat produces a minor bump and a Features section. fix and perf produce a patch bump, under Bug Fixes and Performance Improvements. A breaking change, marked either with a BREAKING CHANGE footer or a ! after the type or scope, produces a major bump. docs, chore, test and refactor are recorded in history but produce no version bump and no changelog entry, which is deliberate: they are not things a consumer of your software needs to read about.
- How do I adopt this in a repo with years of messy commits?
- You do not have to touch the history. semantic-release only reads commits since the most recent git tag, so tag your current state as whatever version makes sense, add the commitlint hook, and everything from that point forward is enforced and parsed. The old commits stay exactly as they are and are simply never analysed.
- Is this worth it if we don't publish to npm?
- Yes, and arguably the value is clearer. Drop the npm plugin and keep the changelog and GitHub release plugins. What you get is a deployment log that writes itself, a tagged version for every release you can roll back to, and the exact commit range that went out in each one. During an incident, "what changed between these two deploys" becomes a link rather than an investigation.
- Won't enforcing a commit format slow the team down?
- Less than expected, because the feedback is immediate. The hook rejects a malformed message the moment you try to commit, with an error saying what is wrong, so the correction takes seconds. The cost people actually feel is learning the types, which is why starting with just feat and fix works: two types, no scope required, and you still get automatic version bumps and a usable changelog.
- How long does the setup take?
- About two hours end to end. Install commitlint and husky and wire up the hook, add the semantic-release config, write the GitHub Actions workflow that runs it on pushes to main, and create the initial tag. After that it runs on every merge with no further attention, which is what makes the two hours pay back quickly.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored