commitlint is the small tool that makes every other piece of changelog automation work. It checks each commit message against Conventional Commits and rejects the ones that don’t fit, locally in a git hook and again in CI. Here’s how to set it up, what the default rules actually enforce, how to handle squash merges, and where its job ends.
If you’ve adopted Conventional Commits, you’ve already discovered the problem: the convention is only worth anything if everyone follows it every time, and nobody does. One fixed the thing commit on main is one changelog entry that never gets generated, or one version bump that doesn’t happen. commitlint exists to make the convention mechanical. It reads the message, parses it into type, scope, subject, body, and footer, runs rules against each part, and exits non-zero when one fails.
It’s been around since 2016, it’s maintained under the conventional-changelog organisation, and it’s used by enough projects that the official docs are the only real documentation on the subject. Search for it and you get package mirrors and Stack Overflow questions. This post is the guide that sits in between: the setup that works in 2026, what the default preset really checks, the GitHub Actions options, the squash-merge decision most teams get wrong, and the errors you’ll hit on day one.
What commitlint checks
Given a message, commitlint expects this shape:
type(scope?): subject
body?
footer?
The README’s examples are chore: run tests on travis ci, fix(server): send cors headers, and feat(blog): add comment section. Scope is optional, and multiple scopes are allowed with /, \ or , as the delimiter.
commitlint itself has no opinion about what the rules should be. It ships a rule engine and a list of around forty rules (type-enum, subject-case, header-max-length, scope-enum, body-leading-blank, references-empty, signed-off-by, and so on), each of which can be set to off, warning, or error, and to always or never. The opinions come from a shareable config you extend. In practice almost everyone uses @commitlint/config-conventional, which is what the rest of this post assumes.
What @commitlint/config-conventional actually enforces
This matters because the preset is stricter than the Conventional Commits specification, and the extra rules are where people get tripped up. The config’s own README is upfront that the stylistic rules come from the Angular convention commitlint grew out of, and that they “narrow the specification — they do not conflict with it.” Here’s the full list as of October 2026.
Errors (non-zero exit):
| Rule | What it enforces |
|---|---|
type-enum |
Type must be one of build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test |
type-case |
Type is lowercase (FIX: fails) |
type-empty |
Type can’t be missing (: some message fails) |
subject-empty |
Subject can’t be missing (fix: fails) |
subject-case |
Subject is not sentence-case, start-case, pascal-case, or upper-case (fix: Some message fails; fix: some Message passes) |
subject-full-stop |
Subject doesn’t end with . |
header-max-length |
First line is 100 characters or fewer |
body-max-line-length |
Each body line is 100 characters or fewer (lines containing URLs are exempt) |
footer-max-line-length |
Each footer line is 100 characters or fewer (URLs exempt) |
Warnings (printed, exit code still zero):
| Rule | What it enforces |
|---|---|
body-leading-blank |
A blank line between header and body |
footer-leading-blank |
A blank line between body and footer |
Two things to notice. The specification allows any type; the preset restricts you to eleven. And the specification says nothing about capitalisation; the preset forbids a capitalised first word in the subject, which is the single most common failure for teams coming from a “Fix the login bug” habit.
If you want the plain specification without the style rules, override them. The config README’s own example:
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"body-max-line-length": [0],
"footer-max-line-length": [0],
},
};
A rule set to [0] is disabled. The full form is [level, applicability, value], so "type-enum": [2, "always", ["feat", "fix", "docs", "chore"]] would restrict types further, and "subject-case": [0] would stop the capitalisation complaints entirely.
Local setup: the commit-msg hook
Install the CLI and the preset, then write a config that extends it. From the getting-started guide:
npm install -D @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
commitlint will also pick the config up from .commitlintrc, .commitlintrc.json, .commitlintrc.yaml, any of the .js/.cjs/.mjs/.ts variants, or a commitlint field in package.json. The file name matters for one of the GitHub Actions below, so note it.
Now the hook. commitlint has to run as a commit-msg hook, not pre-commit, because at pre-commit time the message doesn’t exist yet. The docs are blunt about this: “currently pre-commit hook is not supported.” Husky is the usual way to manage it. For Husky v9:
npm install --save-dev husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
The --edit $1 tells commitlint to read the message from the file git passes to the hook. --no stops npx from installing anything if the CLI is somehow missing, so a misconfigured machine fails loudly instead of silently downloading a random version. The yarn, pnpm, bun, and deno equivalents are in the local setup guide, along with Windows variants that avoid an encoding issue with echo.
Test it with a bad commit. The output in the docs is exactly what you’ll see:
git commit -m "foo: this will fail"
⧗ --- input ---
foo: this will fail
✖ type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]
✖ found 1 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
husky - commit-msg script failed (code 1)
A good commit prints nothing. Since version 8, commitlint is silent on success unless you pass --verbose, which confuses people the first time. To check the last commit without committing again:
npx commitlint --from HEAD~1 --to HEAD --verbose
And to try the tool with no config file at all, pipe a message in and use the built-in default:
echo "feat: add new feature" | npx commitlint --default-config
One practical detail: the ⓘ Get help line points at the commitlint README by default. Set helpUrl in your config to a page in your own repo that explains your convention, and the error message becomes onboarding.
CI setup: hooks are advisory, CI is the gate
Local hooks are fast feedback, and they’re also trivially bypassed: git commit --no-verify, a fresh clone without npm install, a GUI client that doesn’t run hooks. The CI setup guide says it plainly: local linting “can easily be tinkered with,” so lint on CI too.
The official GitHub Actions example lints the last commit on push and the whole range on a pull request:
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm install -D @commitlint/cli @commitlint/config-conventional
- name: Validate current commit
if: github.event_name == 'push'
run: npx commitlint --last --verbose
- name: Validate PR commits
if: github.event_name == 'pull_request'
run: npx commitlint --from $ --to $ --verbose
fetch-depth: 0 is not optional. The default shallow checkout doesn’t contain the base commit, and commitlint can’t lint a range it can’t see. GitLab has the same problem with its default clone depth of 20, which is why the GitLab example sets GIT_DEPTH: 0.
If you’d rather not maintain the workflow, wagoid/commitlint-github-action wraps the same thing in two lines:
name: Lint Commit Messages
on: [pull_request]
permissions:
contents: read
pull-requests: read
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: wagoid/commitlint-github-action@v6
It lints every commit in the PR, falls back to config-conventional if it finds no config, and exposes failOnWarnings, commitDepth, and a results JSON output. Two gotchas from its README: the default config path is commitlint.config.mjs, and a plain commitlint.config.js “doesn’t work with this action” — a JS config must be an ES module with the .mjs extension. And if you use GitHub merge queues, you need a second workflow on the merge_group event with a job of the same name, because the queue looks for a status check that exists in the pull request context.
The squash-merge decision
This is the part most setup guides skip, and it decides whether any of the above achieves anything.
Ask one question: what ends up as a commit on main?
If you squash-merge, the commits on a feature branch are discarded. The commit that lands on main is the squash commit, and its message is the pull request title, provided the repository setting “Default to PR title for squash merge commits” is on. In that world, linting branch commits is theatre. A contributor can push twenty wip commits, and as long as the PR title is feat(reports): add CSV export, the changelog is correct. What you should lint is the title.
The tool for that is amannn/action-semantic-pull-request, which is used by Electron, Vite, Excalidraw, and a long list of others:
name: Lint PR
on:
pull_request_target:
types: [opened, reopened, edited]
jobs:
main:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: $
It validates the title against Conventional Commits, lets you restrict types and scopes, supports a [WIP] prefix, and has a validateSingleCommit option for the case where a PR has exactly one commit and GitHub offers that commit’s message instead of the title. Because titles are a single line, breaking changes have to use ! (refactor!: drop Node 12), which Release Please and semantic-release both understand. Note it runs on pull_request_target, so the workflow has to exist on main before it will check anything, and config changes in a PR only apply to later PRs.
If you merge-commit or rebase-merge, the branch commits reach main as they are, and each one becomes a candidate changelog line. Now the branch commits are what matter, so lint them: the Husky hook locally and the commitlint CLI or the wagoid action in CI.
Plenty of teams run both, a PR-title check as the gate and a local hook as a convenience. What you shouldn’t do is lint commits, squash-merge with a free-text title, and wonder why the generated changelog is empty. If you then feed that main branch into Release Please, it will tell you nothing is releasable, and it will be right.
Errors you’ll hit in the first week
subject-caseonfix: Fixed the login redirect. The preset forbids a capitalised first word. Lowercase it, or disable the rule with"subject-case": [0]if your team genuinely prefers sentence case; just do it on purpose.type-enumonfeature:,bugfix:,hotfix:,update:. Only the eleven listed types pass. Add your own to the enum if you must, but remember that whatever generates your changelog has its own list and may hide types it doesn’t know.header-max-lengthat 100 characters. Usually a ticket key plus a long sentence. Move detail to the body.subject-full-stopon a trailing period. The fix is deleting one character, and it will still happen weekly.body-leading-blankwhengit commit -m "feat: x" -m "body"is used correctly but a GUI client writes the body on the next line. It’s a warning, not a failure.- Nothing printed and no idea if it ran. Silent success. Add
--verbose. - Failures on merge or revert commits. They should be skipped by the default ignores, which cover merge commits,
fixup!andsquash!commits, and git’s automatic revert messages. If they’re failing, look fordefaultIgnores: falsein the config, or a CI range that reaches back past the PR’s base. - CI can’t see the range. Shallow checkout. Set
fetch-depth: 0. - The wagoid action ignores your config. It’s named
commitlint.config.js. Rename it to.mjs.
Making it easier to comply
Rejection is half the tool. The other half is helping people write a valid message in the first place.
Prompts. @commitlint/prompt-cli gives an interactive prompt that walks through type, scope, and subject using your own config’s rules, and @commitlint/cz-commitlint does the same as a Commitizen adapter. For teams that found Conventional Commits intimidating, a prompt removes most of the objections.
Scope presets. @commitlint/config-lerna-scopes, @commitlint/config-nx-scopes, and @commitlint/config-workspace-scopes populate scope-enum from your monorepo’s package list, so feat(api): passes and feat(apii): doesn’t.
A cheat sheet in the repo. The conventional commits post has one you can copy. Point helpUrl at it.
A short grace period. Switch the hook on as warnings first (failOnWarnings: false in the action, or rule level 1 locally), announce the date it becomes an error, and let people see their own mistakes for a week before the gate closes. The conventional commits adoption section covers the people side in more detail.
What commitlint doesn’t fix
commitlint guarantees shape. It confirms the type is on the list, the subject exists, the line fits, and the footer is separated. Pass that gate and Release Please, semantic-release, git-cliff, and conventional-changelog can all do their jobs: compute the version, write the CHANGELOG.md, cut the release. That is a real and valuable guarantee, and it’s the reason to install it.
It cannot check meaning. feat(reports): add CSV export is a perfect conventional commit and a useless sentence to a customer, who wants to know they can now download a report, where the button is, and whether their plan includes it. commitlint will wave through fix: fix without blinking. The commits guide calls this out as the ceiling of the whole commit-parsing approach: the generated output is still developer-facing, because the input was written by developers for developers. The changelog examples post shows the two artifacts side by side, Vue’s generated file next to a customer-facing page, and they share nothing but a date.
So the honest framing is a chain. Conventional Commits is the convention. commitlint is the enforcement. Release Please or its peers are the generation. Each link depends on the one before it, and the chain ends at the repository boundary.
Where ReleasePad fits
ReleasePad starts where that chain stops. It reads the merged pull requests and commits from GitHub and drafts the customer-facing entry, the one that says what changed for the user, for you to edit and publish to a hosted changelog page, an in-app widget, and Markdown feeds. Clean conventional commits make its drafts better, because the type and scope tell it what kind of change it’s looking at, but it reads the diff and the PR description too, so a fix: fix doesn’t leave it with nothing. It doesn’t lint anything and doesn’t replace commitlint; it depends on the same main branch commitlint protects. The GitHub integration page shows the drafting step.
Setup checklist
- Decide what reaches
main: squash commits with PR titles, or branch commits. That decides what you lint. npm install -D @commitlint/cli @commitlint/config-conventional, and acommitlint.config.mjsthat extends the preset. Use.mjsso every tool reads it.- Husky v9,
npx husky init, and a.husky/commit-msgcontainingnpx --no -- commitlint --edit $1. - For squash-merge repos:
amannn/action-semantic-pull-requestonpull_request_target, and switch on “Default to PR title for squash merge commits” in repository settings. - For merge-commit repos:
wagoid/commitlint-github-action@v6onpull_request, or the official workflow withfetch-depth: 0. - Set
helpUrlto your own cheat sheet. Decide which style rules you’ll keep and disable the rest explicitly. - Run as warnings for a week, then make the check required in branch protection.
- Then point Release Please or your generator at
main, and decide separately what customers hear about it.
Rules and commands are from the commitlint docs, the @commitlint/config-conventional README, and the READMEs of wagoid/commitlint-github-action and amannn/action-semantic-pull-request, read on 16 October 2026. Rule defaults change between major versions; if yours differ, the installed package’s README wins.
Further reading
- Conventional Commits: The Developer’s Guide to Better Changelogs: the convention commitlint enforces, and how to introduce it to a team.
- Release Please: How Google’s Release Automation Works: what consumes the commits once they’re clean.
- How to Automate Release Notes from GitHub Commits: generators compared, and the ceiling of commit-based output.
- Semantic Versioning Explained: what
feat,fix, and!mean for the version number. - 12 Changelog Examples Worth Studying: generated files and customer-facing pages, side by side.
- Changelog vs Release Notes: why a valid commit and a good release note are different artifacts.
Frequently Asked Questions
What is commitlint?
commitlint is a command-line tool that checks whether a git commit message follows a convention, almost always Conventional Commits via the @commitlint/config-conventional preset. It parses the message into type, scope, subject, body, and footer, runs a set of rules against each part, and exits non-zero when one fails. You run it in a commit-msg git hook so bad messages are rejected before they exist, and again in CI so nothing slips through on a machine without hooks.
How do I set up commitlint with husky?
Install @commitlint/cli and @commitlint/config-conventional as dev dependencies, create a commitlint.config.js that extends the conventional config, then install husky and run npx husky init. Add a .husky/commit-msg file containing npx --no -- commitlint --edit $1. The hook has to be commit-msg; commitlint does not support pre-commit, because the message does not exist yet at that stage. Test it with a deliberately bad commit and you should see a type-enum error.
What rules does @commitlint/config-conventional enforce?
It enforces the Conventional Commits structure plus a few Angular-style rules the spec itself leaves open. The type must be one of build, chore, ci, docs, feat, fix, perf, refactor, revert, style, or test, and must be lowercase. The subject cannot be empty, cannot end with a full stop, and cannot be in sentence case, start case, pascal case, or upper case. The header is limited to 100 characters, and body and footer lines to 100 characters each, with URLs exempt. Body and footer should start after a blank line; those two are warnings rather than errors.
Should I lint commits or pull request titles?
If your repository squash-merges with the default to PR title for squash merge commits setting on, the PR title becomes the commit on main, so that is what you should lint. Use amannn/action-semantic-pull-request for the title and let contributors write whatever they like on their branches. If you merge or rebase-merge and the individual commits reach main, lint the commits instead, with wagoid/commitlint-github-action or the commitlint CLI in a workflow. Linting the wrong one is the most common way to end up with a green check and a broken changelog.
Why does commitlint fail on a merge commit or a revert?
By default it should not. commitlint ships a list of default ignores that skip merge commits, fixup! and squash! commits, and git's automatic revert messages, so those never hit your rules. If you are seeing failures on them, check whether someone set defaultIgnores to false in the config, or whether your CI step is passing a commit range that includes commits from the base branch rather than just the pull request's commits.
Does commitlint make my changelog good?
It makes the changelog possible, not good. commitlint guarantees that every commit on main has a valid type and a parsable shape, which is exactly what Release Please, semantic-release, and git-cliff need to generate a CHANGELOG.md and compute a version. It cannot check whether feat: add export says anything a customer would understand. That translation, from a correctly formatted commit into a sentence about what changed for the user, is still a writing step, done by a person or by a tool that drafts it from the merged work.
Ready to put this into practice?
Your changelog shouldn't be an afterthought.
ReleasePad makes it easy to publish great release notes — from a public changelog page to an in-app widget, GitHub integration, and analytics. Free to get started.
Get started — it's free