Conventional Commits Without Hand-Written Changelogs
Set up commitlint, commitizen, and semantic-release to generate changelogs from commit messages, with zero hand-editing and a clean git history.

Every changelog I see that was written by hand is either incomplete, full of "various bug fixes and improvements," or silently wrong about which PR actually introduced a regression. The commit message is the single source of truth—if your tooling can't derive a changelog from it, you're doing double-entry bookkeeping with prose. This article walks through the setup that eliminates hand-edited changelogs entirely: commitlint and husky to enforce the format, commitizen to make writing it painless, and semantic-release to generate version bumps and release notes from nothing but the commit stream.
Why Conventional Commits Matter for Tooling
The Conventional Commits spec is simple: a commit message has a type (fix, feat, docs, chore, etc.), an optional scope in parentheses, an optional ! to flag a breaking change, and a body that may contain a BREAKING CHANGE: footer. A minimal example:
fix(api): handle empty query string in search endpointA breaking change:
feat(api)!: switch authentication to OAuth 2.0
BREAKING CHANGE: The `Authorization` header now expects a Bearer token.
Previous API keys are no longer valid.The spec buys you nothing if you read it as a style guide for humans. The payoff is machine parseability: a tool can read fix and bump the patch version, read feat and bump the minor, detect ! or the BREAKING CHANGE footer and bump the major. It can group commits by scope, filter out chore commits from the changelog, and produce a deterministic release note every time.
Without this structure, every automation becomes a regex nightmare against freeform messages. You end up maintaining a list of "known prefixes" that someone inevitably forgets to update, or you rely on GitHub labels that disappear after a merge. The commit message is the only artifact that survives a rebase, a squash, and a migration to another platform. Treat it as a contract with your future self and CI.
Compare this to Gitmoji, which encodes the same semantics in emoji. Gitmoji is visually distinctive, but it lacks a formal spec for breaking-change detection, and most tooling treats an emoji as an opaque token. Conventional Commits wins because the parser is a ten-line function and the spec is unambiguous.
Local Setup: commitlint, husky, and commitizen
Install the three packages that enforce and assist the format:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky commitizen cz-conventional-changelogConfigure commitlint with a commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-enum': [2, 'always', ['api', 'ui', 'auth', 'core']],
},
};The scope-enum rule is optional but useful if you want to enforce a closed set of scopes early. I've seen teams skip it and end up with scope: [auth] in one commit and scope: [authentication] in the next.
Set up husky to run commitlint on the commit-msg hook. If you're on husky v9:
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msgFor commitizen, add a config block to package.json:
{
"scripts": {
"commit": "cz"
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
}Now npm run commit launches an interactive prompt that builds the message for you. You pick the type, type the scope, type the description, and optionally add a body and footer. No need to memorize the spec.
This setup catches 90% of violations before they reach CI. The only gap is developers who commit through VS Code's source control panel or a GUI that doesn't invoke the husky hook. For those, you either enforce a PR-level check (covered in CI Integration) or accept the risk and rely on CI to catch it.
Generating the Changelog: semantic-release vs. standard-version
Two mainstream tools derive a changelog from conventional commits: semantic-release and standard-version. Both parse commits, determine a semver bump, and generate release notes. The differences are in scope and automation.
| Aspect | semantic-release | standard-version |
|---|---|---|
| Version bump | Fully automatic | Manual command (npx standard-version) |
| npm publish | Built-in plugin | Not included |
| GitHub/GitLab release | Built-in plugin | Not included |
| CI token required | Yes (for publish and release) | No (runs locally or in CI) |
| Monorepo support | Via plugins or multi-semantic-release | Manual per-package invocation |
| Changelog overwrite | Writes to CHANGELOG.md | Writes to CHANGELOG.md |
| Learning curve | Higher (plugin architecture, CI config) | Lower (single command, no lifecycle hooks) |
I default to semantic-release for any package published to npm or that needs a GitHub release. The fully automated pipeline eliminates the "did someone run the release command?" question. For internal libraries or projects that only need a CHANGELOG.md file, standard-version is simpler—no CI token, no branch restrictions, just a script in package.json.
For monorepos, both tools add complexity. semantic-release with @semantic-release/exec or multi-semantic-release can handle per-package versioning, but the configuration is brittle. pnpm Workspaces: Filters, Catalogs, and CI Caching covers the workspace setup that makes monorepo releases tractable. If you only need a single changelog for the whole repo, standard-version with a conventional commit parser on the root is often enough.
Configuring semantic-release for a Clean Changelog
A minimal .releaserc.json that generates a changelog, bumps the version, and publishes to npm:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}The commit-analyzer plugin maps commit types to semver bumps by default:
fix→ patchfeat→ minor- Any commit with
!or aBREAKING CHANGEfooter → major
You can extend this with releaseRules in the plugin config. For example, to treat perf as a patch bump:
{
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{"type": "perf", "release": "patch"}
]
}
]
]
}The @semantic-release/changelog plugin writes the new release notes to CHANGELOG.md. The @semantic-release/git plugin commits that file back to the repo with a [skip ci] marker to prevent an infinite CI loop. You never touch CHANGELOG.md manually. If someone opens a PR that modifies it, that's a red flag—the CI check described later should fail it.
The @semantic-release/npm plugin handles npm publish and updates the version in package.json. If you don't need publishing, remove that plugin and adjust the git assets list accordingly.
Handling Breaking Changes and Scoped Commits
Two patterns flag a breaking change:
- An exclamation mark before the colon:
feat(api)!: drop v1 endpoints - A
BREAKING CHANGE:footer in the commit body (must be uppercase, with the colon):
feat(api): drop v1 endpoints
BREAKING CHANGE: Removed all /v1/ routes. Migrate to /v2/ equivalents.The analyzer checks for both. The footer pattern is safer because it's harder to miss in a squash-merge scenario where the PR title is the only visible text—if the squash commit message includes the body, the footer is preserved.
Scopes group related changes in the changelog. A commit like feat(ui): add dark mode toggle appears under a "ui" heading in the generated notes. Good scopes are module- or layer-level: api, ui, auth, core, docs. Avoid scopes that mirror file names or component names—feat(Button) is too granular and creates visual noise.
Common mistakes I see:
- Forgetting the
BREAKING CHANGEfooter. The!marker is easy to add, but if someone writesfeat: rewrite authwithout either marker, the analyzer treats it as a minor bump. Users downstream get a breaking change at patch or minor version, which violates semver. - Multiple features in one commit.
feat: add user search and paginationis one commit with two features. Split them. The changelog can't isolate the two changes, and a revert becomes impossible without losing both. - Using
chorefor everything. If every small refactor ischore, the changelog is empty. Userefactorfor structural changes,stylefor formatting, andtestfor test-only changes. The commit-analyzer treatsrefactorandstyleas patch-level by default, so they still appear in the changelog.
Failure Modes and Gotchas in Practice
Merge commits from PRs bypass local hooks if you use squash-and-merge with a non-conventional PR title. The squash commit message defaults to the PR title, which is often "Fix search bug #123"—not a conventional commit. The fix is one of:
- Enforce a conventional commit format on PR titles using a GitHub action like
amannn/action-semantic-pull-request. - Teach the team to write the squash message manually. GitHub allows editing the commit message at merge time.
- Use a merge queue that blocks on title format.
Semantic-release only bumps on the default branch. If you work on long-lived feature branches, you need to squash-merge with a conventional message or use the next release channel for pre-releases. Without that, commits on a feature branch never trigger a release, and you accumulate unreleased changes that all land at once.
Changelog bloat happens when every commit is feat. A small UI tweak is not a feature—it's refactor or chore. The conventional-changelog-conventionalcommits preset for semantic-release groups commits better than the default preset: it sorts by scope and filters chore commits from the notes. Consider switching the preset in .releaserc.json:
{
"plugins": [
["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }],
["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }]
]
}CI Integration and Verification
In GitHub Actions, two jobs cover the common workflows:
- A PR check that validates existing commits against the conventional commit format.
- A push-to-main job that runs semantic-release.
The PR validation job uses commitlint's --from and --to flags to check all commits in the PR:
name: Validate Commits
on: [pull_request]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- run: npm ci
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}The fetch-depth: 0 is necessary to get the full git history for the range check. Without it, commitlint can't see the base commit.
For the release job, add a check that fails if CHANGELOG.md is modified in a PR. Since it's auto-generated, any manual edit suggests someone bypassed the tooling:
name: Check Changelog Not Modified
on: [pull_request]
jobs:
check-changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git diff --name-only origin/main...HEAD | grep -q CHANGELOG.md && exit 1 || exit 0The release job itself is a standard semantic-release workflow:
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}The persist-credentials: false prevents the default token from interfering with semantic-release's git operations. The GITHUB_TOKEN is automatically available; NPM_TOKEN is a secret you create in the repo settings.
For monorepos, run semantic-release per-package or use multi-semantic-release. The per-package approach requires each package to have its own .releaserc.json and CI job, which duplicates configuration. The multi-semantic-release tool runs semantic-release across all packages in a workspace, but it introduces its own edge cases around dependency ordering. If you're already using pnpm workspaces, the filter syntax in pnpm Workspaces: Filters, Catalogs, and CI Caching gives you a clean way to scope releases to changed packages.
The semantic-release documentation is the canonical reference for plugin configuration and CI integration. For commitlint, the commitlint docs cover the full rule set and shared configuration options. The Conventional Commits spec itself is worth reading once—it's short and precise.
Key takeaways
- Conventional Commits is a machine-readable contract, not a style guide. The spec's value is in deterministic version bumps and changelog generation, not in making messages look uniform.
- commitlint + husky enforces the format locally; commitizen removes the friction of writing compliant messages. Install all three before you worry about changelog generation.
- semantic-release automates the full pipeline (bump, changelog, publish, GitHub release) on push to the default branch. standard-version is a lighter alternative if you only need a changelog file.
- Breaking changes must be flagged with
!or aBREAKING CHANGE:footer. The analyzer checks both, but the footer survives squash merges better. - CI checks for commit format and changelog tampering prevent the most common failure modes. A PR that modifies
CHANGELOG.mdis a sign someone is hand-editing—fail the build.
Frequently asked questions
- Do I need to use commitizen, or can I write conventional commits manually?
- You can write them manually; commitizen just prevents typos and enforces formatting interactively. The real requirement is the commit-msg hook with commitlint, which rejects malformed messages. If your team is disciplined, manual works; commitizen reduces friction for new members.
- How do I handle a breaking change without a major version bump?
- Semantic-release follows semver by default, so a 'feat!' or 'BREAKING CHANGE' footer always triggers a major bump if you're on 1.x or higher. If you're on 0.x, it treats breaking changes as a minor bump by default. You can override this with releaseRules in your config, but it's usually better to follow the default.
- Can I generate a changelog without semantic-release, just from git history?
- Yes. Tools like 'conventional-changelog-cli' or 'standard-version' can generate a CHANGELOG.md from your commit history without publishing or tagging. They read the git log, parse conventional commits, and write a changelog. This is a good option if you want a changelog but don't want automated releases.


