commitlint, set up so it actually blocks a bad commit
I installed commitlint 21 in a scratch repo, wired it to a git hook, and kept every error it printed. Two of them came from following the official setup steps exactly. The commands and errors below all ran there, with the output pasted as it came.
Definition
What commitlint is
commitlint is a command-line linter for commit messages. It reads a message, checks it against a list of rules, and exits with an error when one breaks, so a git hook or a CI job can refuse the commit. You want it when a release tool reads your history, because release-please or semantic-release can only version what they can parse. commitlint ships with no rules of its own. You point it at a shared config, and nearly everyone uses @commitlint/config-conventional, which enforces the Conventional Commits format plus some house rules: 11 allowed types, lower-case types, no capital at the start of the description, no full stop, a 100-character first line. It runs in two places. A commit-msg hook gives you the error before the commit exists, and CI catches everything that skipped the hook. I tested version 21.2.3 on Node 24.
Free tool
Try the rules first
This checker applies commitlint’s default rules in the browser, the same config-conventional set the rest of this page installs. Paste a message and see what would fail before you wire anything up.
Commit checker
release-please
2.0.0 major
A breaking change.
semantic-release, default config
1.5.0 minor
At least one feat. A "!" commit was skipped.
feat(search): find frogs by locationfeatscope: searchspec: minor bumpchangelog: Features
Valid under the spec and commitlint’s default config.
fix: stop the map jumping on zoomfixspec: patch bumpchangelog: Bug Fixes
Valid under the spec and commitlint’s default config.
feat!: drop the v1 frogs endpointfeatbreakingspec: major bumpchangelog: BREAKING CHANGES
Valid under the spec and commitlint’s default config.
chore: bump eslintchorespec: no version meaningchangelog: Miscellaneous Chores (hidden)
Valid under the spec and commitlint’s default config.
docs: Fix typo in README.docsspec: no version meaningchangelog: Documentation (hidden)
- commitlint · subject-case commitlint rejects a description that starts with a capital letter. Lower-case the first word.
- commitlint · subject-full-stop No full stop at the end of the description.
This tells you what the release tools will do with your commits. Writing the post about the release is a separate job, and that one I automated.
Setup
Install and a config that loads
npm install -D @commitlint/cli @commitlint/config-conventionalThe getting-started guide then has you write commitlint.config.js containing export default. That works if your package.json has no "type" field, or has "type": "module". My scratch repo came from npm init -y on npm 11, which writes "type": "commonjs", and the config died on the first line:
C:\...\repo\commitlint.config.js:1
export default { extends: ['@commitlint/config-conventional'] };
^^^^^^
SyntaxError: Unexpected token 'export'So I name the file .mjs. Node always treats that extension as an ES module, and commitlint picked it up with no other change:
// commitlint.config.mjs
export default { extends: ['@commitlint/config-conventional'] };Now pipe it a message:
echo "feat: Add login" | npx commitlint⧗ --- input ---
feat: Add login
✖ subject must not be sentence-case [subject-case]
✖ found 1 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlintA message that passes prints nothing and exits 0. Add --verbose to see ✔ found 0 problems, 0 warnings instead of silence. Since v21 the input goes on its own line under --- input ---; if a script of yours parsed the old ⧗ input: ... format, --legacy-output brings it back. And if you just want to try it before writing any config, npx commitlint --default-config runs with config-conventional built in.
Local
The commit-msg hook
commitlint’s local setup guide uses husky, and so did I (husky 9.1.7). The hook has to be called commit-msg. The guide says plainly that pre-commit isn’t supported.
npm install --save-dev husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msgOne thing the guide doesn’t mention. husky init also creates .husky/pre-commit containing npm test. In a fresh npm project the test script is the placeholder that exits 1, so my first commit failed before commitlint even ran:
> repo@1.0.0 test
> echo "Error: no test specified" && exit 1
"Error: no test specified"
husky - pre-commit script failed (code 1)Delete .husky/pre-commit or put a real command in it. After that, a bad message stops the commit:
$ git commit -m "Add login"
⧗ --- input ---
Add login
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
husky - commit-msg script failed (code 1)On Windows, don’t write the hook file with PowerShell’s >. On my machine it saved the file with a byte-order mark, and git’s shell read the mark as part of the command:
.husky/commit-msg: line 1: $'\357\273\277npx': command not found
husky - commit-msg script failed (code 127)Writing it through Node, the same trick the getting-started guide uses for the config on Windows, gave a clean file that worked:
node -e "fs.writeFileSync('.husky/commit-msg', process.argv[1])" 'npx --no -- commitlint --edit $1'A hook is a suggestion. git commit --no-verify skips it, and I did exactly that to get a bad commit into the test repo. That’s why the next section exists.
Enforcement
GitHub Actions
This is the workflow from commitlint’s CI guide, with checkout and setup-node bumped to v7, their current majors. The guide still shows v4. On a push it checks the last commit; on a pull request it checks every commit between the base and the head. I haven’t run these two workflows on GitHub itself. The commitlint commands inside them I ran locally.
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup node
uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- name: Install commitlint
run: npm install -D @commitlint/cli @commitlint/config-conventional
- name: Validate current commit (last commit) with commitlint
if: github.event_name == 'push'
run: npx commitlint --last --verbose
- name: Validate PR commits with commitlint
if: github.event_name == 'pull_request'
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verboseKeep fetch-depth: 0. --from and --to read every commit between two SHAs, so the job needs that history. I ran both commands in the scratch repo: --last and --from HEAD~1 --to HEAD each flagged the feat: Add frog search I had committed past the hook.
Squash merges
Linting PR titles for squash merges
If you squash merge, the PR title becomes the commit on main, and the branch commits disappear. Linting those branch commits then checks messages nobody will ever read. Lint the title instead:
name: PR title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
jobs:
title:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
- run: npm install -D @commitlint/cli @commitlint/config-conventional
- name: Lint the PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE" | npx commitlint --verboseTwo details matter. By default a pull_request workflow only runs on opened, synchronize and reopened, so without edited the check never reruns when someone fixes the title. And the title goes through an environment variable, not straight into the run line, because GitHub’s security guide treats a PR title as untrusted input that could inject shell. I ran the lint step locally with the title in $TITLE: Add CSV export failed on subject-empty and type-empty, feat: add CSV export passed.
Config
The rules people fight with
Why “Add login” fails. config-conventional’s subject-case rule bans sentence case, start case, Pascal case and upper case. In practice that means any description starting with a capital fails. feat: Add login fails, and so does chore: OAuth token refresh, even though OAuth is spelled right. Only the start matters: feat: add CSV export passes. A message written the way most people were taught to write sentences can break four rules at once:
⧗ --- input ---
Fix: Login button.
✖ subject must not be sentence-case [subject-case]
✖ subject may not end with full stop [subject-full-stop]
✖ type must be lower-case [type-case]
✖ type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]
✖ found 4 problems, 0 warningsCustom types. Each rule is an array: a level (0 off, 1 warning, 2 error), 'always' or 'never', then a value. To allow a deps type, override type-enum. The override replaces the list, it doesn’t add to it. I tried ['deps'] on its own and feat started failing. So repeat the defaults. This config also switches subject-case off, if your team writes descriptions with capitals and would rather keep doing that:
// commitlint.config.mjs
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'deps'],
],
'subject-case': [0],
},
};With that file, deps: bump eslint to 9 and feat: Add login pass, and wip: half done still fails with the new list in the message. If you use a release tool, tell it about the new type too, or it will parse the commit and leave it out of the changelog.
What it skips. Merge commits (Merge pull request #12 from ..., Merge branch ...), Revert "...", fixup!, squash! and amend! commits, and a bare version number all pass without being checked. So GitHub merge commits won’t fail your CI.
Warnings don’t block. A body with no blank line above it prints body must have leading blank line [body-leading-blank] and still exits 0.
Troubleshooting
Errors and what they mean
SyntaxError: Unexpected token 'export'. Your config usesexport defaultin a.jsfile inside a CommonJS package. Rename it tocommitlint.config.mjs.Please add rules to your `commitlint.config.js`with the rule name[empty-rules], and exit code 9. commitlint found no config at all. You get the full message below. It’s the fix list, so read it.subject may not be empty [subject-empty]together withtype may not be empty [type-empty]. The header didn’t parse. Usually no type (Add login), no space after the colon (feat:add login) or a space before it (feat : add x).type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]. You wrotefeature:orwip:. Use one from the list, or add yours as shown above.header must not start with whitespace [header-trim]. There’s something invisible before the type, a leading space or a byte-order mark from a Windows tool.
✖ Please add rules to your `commitlint.config.js`
- Getting started guide: https://commitlint.js.org/guides/getting-started
- Example config: https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/src/index.ts
- Or run commitlint with the built-in default config: commitlint --default-config [empty-rules]The ecosystem
Where it fits
commitlint only says no. Commitizen is the other half: it asks you for the type and description and writes a message that passes. Downstream, release-please and semantic-release read the commits commitlint let through and turn them into version numbers and changelogs. If you only need the rules in front of you, the cheat sheet has the full rule table on one screen.
Honesty
What this doesn’t fix
A linted history gets you a release tool that works and a changelog with the right headings. It doesn’t get anyone outside the repo to hear that you shipped. feat(search): find frogs by location passes every rule on this page and would make a terrible post.
That gap is what I built Merge & Tell for. It reads each merged pull request and drafts the announcement, and I read each draft before it goes anywhere. The updates page is it running on this product.
The linter passed. Nobody outside the repo noticed.
You were going to merge the PR anyway.