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 1

CI 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
          fi

CI 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
              fi

CI 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
      fi

CI 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 messages

Linting 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