An astronaut walks down a hall of glowing data pillars under a dome that opens onto a purple nebula.
Guide

commitlint: How to Enforce Conventional Commits Locally and in CI (Setup, Rules, and the Squash-Merge Trap)

Felix Macx · · 13 min read

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-case on fix: 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-enum on feature:, 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-length at 100 characters. Usually a ticket key plus a long sentence. Move detail to the body.
  • subject-full-stop on a trailing period. The fix is deleting one character, and it will still happen weekly.
  • body-leading-blank when git 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! and squash! commits, and git’s automatic revert messages. If they’re failing, look for defaultIgnores: false in 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

  1. Decide what reaches main: squash commits with PR titles, or branch commits. That decides what you lint.
  2. npm install -D @commitlint/cli @commitlint/config-conventional, and a commitlint.config.mjs that extends the preset. Use .mjs so every tool reads it.
  3. Husky v9, npx husky init, and a .husky/commit-msg containing npx --no -- commitlint --edit $1.
  4. For squash-merge repos: amannn/action-semantic-pull-request on pull_request_target, and switch on “Default to PR title for squash merge commits” in repository settings.
  5. For merge-commit repos: wagoid/commitlint-github-action@v6 on pull_request, or the official workflow with fetch-depth: 0.
  6. Set helpUrl to your own cheat sheet. Decide which style rules you’ll keep and disable the rest explicitly.
  7. Run as warnings for a week, then make the check required in branch protection.
  8. 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

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.

commitlint conventional-commits husky github-actions changelog automation

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
Try me now!