Tools & automation
One contract — ten types, an optional scope, a 72-character header — enforced at every layer.
Commit hooks
A plain POSIX shell script — nothing to install. It runs on every commit and rejects malformed messages immediately, where feedback is cheapest. Wire it up through your hook manager of choice so it is shared with the whole team.
#!/bin/sh
# .git/hooks/commit-msg — no dependencies to install, just make it executable
pattern='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(\([^()]+\))?!?: .{1,72}$'
subject=$(head -n 1 "$1")
if printf '%s\n' "$subject" | grep -qE "$pattern"; then
exit 0
fi
echo "invalid commit message: $subject" >&2
echo "expected: type(scope)!?: subject (1-72 chars)" >&2
exit 1CI on GitHub Actions
Runs on every pull request. fetch-depth: 0 gives the runner full history — a shallow checkout would let the check pass without linting anything. Hooks can be skipped with flags; CI cannot.
# .github/workflows/commit-messages.yml
name: commit messages
on:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # full history — a shallow checkout would skip the check
- name: validate commit messages
env:
target_branch: ${{ github.base_ref }}
run: |
pattern='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(\([^()]+\))?!?: .{1,72}$'
if git log --format='%s' "origin/$target_branch..HEAD" | grep -qvE "$pattern"; then
git log --format='%s' "origin/$target_branch..HEAD" | grep -vE "$pattern" >&2
echo "^^ invalid commit message(s) — expected: type(scope)!?: subject (1-72 chars)" >&2
exit 1
fiCI on Bitbucket Pipelines
Pull-request pipelines expose the destination branch in BITBUCKET_PR_DESTINATION_BRANCH, and clone: depth: full keeps the whole range lintable.
# bitbucket-pipelines.yml
image: alpine/git:latest
clone:
depth: full
pipelines:
pull-requests:
"**":
- step:
name: validate commit messages
script:
- |
pattern='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(\([^()]+\))?!?: .{1,72}$'
target_branch="$BITBUCKET_PR_DESTINATION_BRANCH"
if git log --format='%s' "origin/$target_branch..HEAD" | grep -qvE "$pattern"; then
git log --format='%s' "origin/$target_branch..HEAD" | grep -vE "$pattern" >&2
echo "^^ invalid commit message(s) — expected: type(scope)!?: subject (1-72 chars)" >&2
exit 1
fiCI on GitLab CI
Merge-request pipelines expose the target branch as CI_MERGE_REQUEST_TARGET_BRANCH_NAME. The minimal alpine job image only needs git.
# .gitlab-ci.yml
commit-messages:
image: alpine:latest
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
before_script:
- apk add --no-cache git
script:
- |
pattern='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(\([^()]+\))?!?: .{1,72}$'
target_branch="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
if git log --format='%s' "origin/$target_branch..HEAD" | grep -qvE "$pattern"; then
git log --format='%s' "origin/$target_branch..HEAD" | grep -vE "$pattern" >&2
echo "^^ invalid commit message(s) — expected: type(scope)!?: subject (1-72 chars)" >&2
exit 1
fiCI on Azure DevOps
fetchDepth: 0 overrides shallow fetch so the full range is visible; the target branch arrives as SYSTEM_PULLREQUEST_TARGETBRANCH. For Azure Repos, PR builds are triggered by branch policies.
# azure-pipelines.yml
pr:
branches:
include:
- "*"
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0 # full history — a shallow fetch would skip the check
- script: |
pattern='^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(\([^()]+\))?!?: .{1,72}$'
target_branch="$SYSTEM_PULLREQUEST_TARGETBRANCH"
if git log --format='%s' "origin/$target_branch..HEAD" | grep -qvE "$pattern"; then
git log --format='%s' "origin/$target_branch..HEAD" | grep -vE "$pattern" >&2
echo "^^ invalid commit message(s) — expected: type(scope)!?: subject (1-72 chars)" >&2
exit 1
fi
displayName: validate commit messagesLinting with commitlint
commitlint encodes the same contract as the hook and CI regex as declarative rules: the same ten types, the same scope list, and the 72-character cap, while config-conventional covers the rest (empty type or subject, trailing period, casing). One shared config per organization keeps every repository enforcing the same rules.
// commitlint.config.js — npm install --save-dev @commitlint/cli @commitlint/config-conventional
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always",
["feat","fix","docs","style","refactor","test",
"chore","build","ci","perf"]],
"scope-enum": [2, "always", ["api","auth","ui","deps"]],
"header-max-length": [2, "always", 72],
},
};Changelog generation
Because commit types are machine-readable, changelogs can be generated instead of written: group feat under Features, fix under Bug Fixes, and list breaking changes at the top.
# derive the changelog from history
git log --format='%s' $(git describe --tags --abbrev=0)..HEAD \
| grep -E '^(feat|fix|perf)' \
| sort