How to Enforce Conventional Commits with Commitlint Jump to heading

Unstructured commit messages break automated delivery pipelines in deterministic ways: semantic versioning tools compute the wrong bump, changelog generators produce empty output, and release automation misfires on ambiguous change severity. The root cause is a missing validation gate at the commit boundary β€” a gap that commitlint closes by intercepting malformed messages before they enter history.

This recipe is a direct extension of the Trunk-Based Development Setup, where linear history and strict header formatting are prerequisites for automated promotion, rollback, and artifact generation.

When to Use This Approach Jump to heading

Apply this recipe when:

  • Your team uses semantic-release, release-please, or another tool that parses commit types to compute version bumps.
  • Changelog generation (conventional-changelog) is part of the release pipeline and currently produces incomplete output.
  • You have a distributed team where some members bypass message conventions under time pressure.
  • You are adopting trunk-based development and need automated promotion gates that depend on commit semantics.
  • You want a complementary local check to reinforce the CI enforcement already provided by your CI/CD pipeline trigger mapping.

If your workflow uses squash-only merges and a single human writes the squash message, a lighter-weight PR title linter may be sufficient. This recipe targets teams where per-commit messages are preserved in the branch history and matter downstream.


Commitlint enforcement flowA four-stage pipeline diagram showing: developer writes commit message, local commit-msg hook runs commitlint, CI pipeline validates the full PR range with commitlint, and release tooling consumes clean conventional commit history.Developergit commit -m"feat(auth): ..."commit-msg hookcommitlint --editβœ“ or βœ— locallyCI pipelinecommitlint --frommerge-base β†’ HEADRelease toolingsemantic-releasereads clean historyLocal fast-feedback ←————————————→ Authoritative enforcement

Step-by-Step Recipe Jump to heading

Step 1 β€” Validate environment and dependencies Jump to heading

Intent: Confirm the runtime and repository state before installing anything.

# Confirm Node.js >=18.x
node -v

# Confirm this is a git repository
git rev-parse --is-inside-work-tree

# Confirm package.json exists at the project root
ls package.json

Verify: All three commands exit 0. If package.json is missing, run npm init -y first. Commitlint resolves configuration relative to the working directory; a missing manifest causes silent fallbacks during CI.

SAFETY WARNING: Run these commands from the repository root. Executing hooks in a detached HEAD state or a shallow clone can produce false negatives during range calculations β€” shallow clones truncate history, breaking --from/--to anchoring.


Step 2 β€” Install commitlint CLI and conventional preset Jump to heading

Intent: Add the validator and its rule preset as dev dependencies.

# Install CLI and the Conventional Commits rule preset
npm install --save-dev @commitlint/cli @commitlint/config-conventional

Verify:

npx commitlint --version
# Expected: 19.x.x (or later)

Step 3 β€” Create commitlint.config.js Jump to heading

Intent: Define the rule set that will govern every commit message in the repository.

// commitlint.config.js
// Extends the Conventional Commits baseline and tightens a few rules
// that matter for semantic-release compatibility.
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Hard limit: keeps subject lines readable in git log --oneline
    'header-max-length': [2, 'always', 72],

    // Explicit allowlist: synchronize this with semantic-release's
    // releaseRules if you customise bump logic.
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert'
    ]],

    // Scopes must be lower-case to prevent changelog duplication
    'scope-case': [2, 'always', 'lower-case'],

    // Subject line must not start with a capital letter or sentence case
    'subject-case': [2, 'never', [
      'sentence-case', 'start-case', 'pascal-case', 'upper-case'
    ]]
  }
};

Verify: Print the fully resolved config to confirm rule inheritance is correct:

npx commitlint --print-config
# Inspect: rules.type-enum should list all your allowed types

SAFETY WARNING: If you add or remove types from type-enum, update semantic-release’s releaseRules and conventional-changelog’s writer options in the same commit. Divergent type lists cause version bumps to be skipped silently β€” feat commits that don’t match a recognised type produce no minor bump.


Step 4 β€” Bind the commit-msg hook via Husky Jump to heading

Intent: Intercept every commit message before Git writes it to the object store. This page covers only the commitlint binding; for the full local hook configuration with Husky β€” including prepare script setup and team-wide hook distribution β€” see that dedicated guide.

# Initialise Husky v9 (creates .husky/ and adds the prepare script)
npx husky init

# Write the commit-msg hook
# --no prevents npx from prompting for package installs in offline environments
# --edit reads the message from the temp file Git passes as $1
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

# Make the hook executable (required on POSIX systems)
chmod +x .husky/commit-msg

Verify: Attempt an intentionally invalid commit:

git commit --allow-empty -m "wip: testing hook"
# Expected: commitlint exits non-zero and prints a structured error
# βœ–  type must be one of [feat, fix, docs, ...] [type-enum]

git commit --allow-empty -m "chore: verify commitlint binding"
# Expected: exits 0 and the commit is created

SAFETY WARNING: Any developer can bypass local hooks with git commit --no-verify. Local validation is a fast-feedback layer, not a security boundary. CI enforcement (Step 5) is mandatory.


Step 5 β€” Add CI validation Jump to heading

Intent: Make commitlint a required status check so no malformed message can reach the default branch, regardless of local hook state.

# Validate the exact commit range introduced by this PR
# git merge-base finds the last common ancestor with origin/main,
# avoiding false positives when the base branch has been updated.
npx commitlint --from $(git merge-base HEAD origin/main) --to HEAD --verbose

Integrate this command into your pipeline. GitHub Actions example:

# .github/workflows/commitlint.yml
name: Lint commit messages

on:
  pull_request:
    branches: [main]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Fetch enough history for merge-base to work correctly
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Validate commit messages
        run: npx commitlint --from $(git merge-base HEAD origin/main) --to HEAD --verbose

Verify: Open a pull request with a malformed commit. The commitlint job must fail and block merging. Merge a PR with valid commits; the job must pass.

SAFETY WARNING: Never use HEAD~N to anchor the range in CI β€” the count is wrong after squash merges or force-pushes. Always use git merge-base HEAD origin/main. Additionally, to complement your pre-push validation rules, consider running commitlint in the pre-push hook as a mid-point gate between local commit-msg and CI.


Validation Checklist Jump to heading

Before rolling this out team-wide, confirm each item:

Troubleshooting Jump to heading

SymptomLikely causeFix
Hook runs but commitlint is not foundnode_modules/.bin not on PATH inside hookUse npx --no -- commitlint (not a bare commitlint call)
CI fails on first commit β€” empty history rangeShallow clone with fetch-depth: 1Set fetch-depth: 0 in actions/checkout
CI fails on squash merges with HEAD~NCount-based range is wrong after squashAnchor with git merge-base HEAD origin/main
Custom type added but semantic-release still skips version bumpreleaseRules in semantic-release config not updatedAdd matching entry to releaseRules in .releaserc
Hook silently passes invalid messages.husky/commit-msg is not executablechmod +x .husky/commit-msg

Frequently Asked Questions Jump to heading

Can developers permanently bypass commitlint? Jump to heading

Yes β€” git commit --no-verify skips all local hooks. This is expected behaviour. Local hooks reduce friction by catching mistakes immediately; CI enforcement is the gate that actually matters for branch protection. Configure your repository to require the CI commitlint status check before merging.

How do I validate only the commits in a pull request, not the entire history? Jump to heading

Use git merge-base to anchor the start of the range to the last common ancestor: npx commitlint --from $(git merge-base HEAD origin/main) --to HEAD. This is accurate regardless of how many commits the PR contains or whether the base branch has been updated.

What happens when the repository has no history yet (first commit)? Jump to heading

On the very first commit, commitlint --edit still runs but there is no ancestor to range against. The hook itself is fine β€” it validates the single message in $1. The CI range command will fail because git merge-base requires at least two commits. Skip the CI commitlint step for the initial scaffold commit, or use || true with a comment explaining the exception.