Enforcing Commit Message Conventions Locally
The release job fails with semantic-release: no relevant changes, so no new version is released after a week of work, because every commit message was wip, fix stuff or Update cart.ts; or the changelog generator crashes on Feat: Add Discounts with a capital letter it does not recognise. When release notes, version bumps or changelogs are derived from commit messages, the message format is part of the build, and finding problems at release time is far too late. This page validates messages locally at commit time and again in CI, as part of pre-commit hooks and local quality gates.
The examples use Conventional Commits (type(scope): description), the most common convention for automated releases; the same mechanics apply to any format that a regular expression can describe.
Diagnostic
See how many recent messages would fail the convention and what the release tooling expects:
#!/usr/bin/env bash
set -euo pipefail
pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: [a-z].{2,}$'
total=$(git log --format=%s -200 origin/main | wc -l)
bad=$(git log --format=%s -200 origin/main | grep -vcE "$pattern|^Merge " || true)
echo "$bad of $total recent subjects do not match"
git log --format=%s -200 origin/main | grep -vE "$pattern|^Merge " | head -5
ls .git/hooks/commit-msg 2>/dev/null || echo "no commit-msg hook installed"
Expected bad output:
47 of 200 recent subjects do not match
wip
fix stuff
Update cart.ts
Feat: Add Discounts
fixed the thing from standup
no commit-msg hook installed
Nearly a quarter of commits would be invisible to the release tooling or break it, and nothing checks messages before they are written.
Root cause
Commit messages are written at the end of a task, quickly, and nothing in plain Git constrains them. Release tooling such as semantic-release, release-please and changesets reads commit types to decide whether a change is a feature, a fix or a breaking change; a message that does not parse is treated as irrelevant, so real features ship without a version bump or changelog entry. Validation that only runs in CI or at release time reports the problem after the commit exists, when fixing it means rewriting history on a shared branch. A commit-msg hook validates while the message is still in the editor, where fixing it costs seconds. Because hooks can be bypassed and some commits are made in web editors or by squash-merging, CI must check too — typically the pull-request title when the team squash-merges, and every commit when it does not.
Resolution
- Add a commit-msg hook to the existing
pre-commitconfiguration and install the stage:
default_install_hook_types: [pre-commit, commit-msg]
repos:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [--strict, --force-scope, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test]
#!/usr/bin/env bash
set -euo pipefail
pre-commit install --hook-type commit-msg
git commit --allow-empty -m "fixed stuff" && echo "unexpectedly accepted" || echo "rejected as expected"
git commit --allow-empty -m "fix(cart): keep totals const"
--force-scope requires a scope, which helps monorepos route changelog entries; drop it if scopes are optional in your convention.
- Help people write valid messages with a template that appears in the editor:
#!/usr/bin/env bash
set -euo pipefail
cat > .gitmessage <<'EOF'
# <type>(<scope>): <description, lower case, imperative>
# types: feat fix perf refactor docs test build ci chore style revert
# scopes: api web worker infra deps
# add "!" after the scope for breaking changes, e.g. feat(api)!: drop v1 routes
EOF
git config commit.template .gitmessage
Put the git config line in the bootstrap target so every clone gets it.
- Check in CI. For squash-merge workflows, validate the pull-request title, which becomes the commit message:
name: pr-title
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
title:
runs-on: ubuntu-24.04
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
docs
test
build
ci
chore
style
revert
requireScope: true
For merge-commit or rebase workflows, check every commit in the pull request instead, for example with commitlint --from origin/main --to HEAD.
- Allow the exceptions Git creates itself. Merge commits (
Merge branch ...), reverts (Revert "...") and fixup commits (fixup! ...) should pass;conventional-pre-commitaccepts them by default, and commitlint's config has anignoreslist for them.
Expected output
$ git commit -m "fixed stuff"
Conventional Commit......................................................Failed
- hook id: conventional-pre-commit
- exit code: 1
[Bad commit message] >> fixed stuff
Your commit message does not follow Conventional Commits formatting
$ git commit -m "fix(cart): keep totals const"
Conventional Commit......................................................Passed
[feat/cart 9b7c1e2] fix(cart): keep totals const
Invalid messages are rejected while they are still in the editor, valid ones pass, and the pull-request title check blocks merging a non-conforming squash title.
The next release shows the effect: the changelog is grouped into features and fixes with scopes, the version bump matches the kinds of change merged, and breaking changes marked with ! appear in their own section. That visible result is what convinces sceptical developers the convention is worth the few characters it costs.
Prevention
Install the hook from the bootstrap target (
pre-commit install --hook-type commit-msg) and check.git/hooks/commit-msgin the doctor script.Keep the type and scope lists in one place. Generate the hook arguments and CI configuration from a single file, or at least review them together, so a new scope is accepted everywhere at once.
Show the rules at the point of failure. A rejection message that prints one valid example saves more time than documentation nobody reads.
Platform caveats
Windows (native): the
conventional-pre-commithook is Python and runs natively. If the team uses commitlint instead, it needs Node onPATHfor GUI Git clients too.
GUI clients and IDEs: most run
commit-msghooks; some older clients do not show hook output clearly. Test one commit through each client the team uses.
macOS and Linux: nothing specific; the template path in
commit.templateis relative to the repository root when set withgit configinside the repository.
Rollback
Remove the hook stage and CI workflow; commits are no longer checked:
#!/usr/bin/env bash
set -euo pipefail
pre-commit uninstall --hook-type commit-msg
git rm .github/workflows/pr-title.yml
git config --unset commit.template || true
Frequently Asked Questions
Should we check every commit or only the pull-request title?
If the repository squash-merges, check the title, because that becomes the only commit on the main branch. If it merges or rebases, check every commit in the pull request, since each one lands in history.
What about wip commits on feature branches?
With squash merges, intermediate messages do not reach the main branch, so the hook can be relaxed on feature branches or developers can use fixup! commits, which the hook accepts. With rebase merges, every commit must conform.
Does the hook slow down committing?
No. Message validation is a regular expression check that takes milliseconds; the framework's environment is created once.
How do we mark a breaking change?
Add ! after the type or scope (feat(api)!: drop v1 routes) or include a BREAKING CHANGE: footer. Release tooling uses either to trigger a major version bump.