← all posts · devops

A changelog you never write: Conventional Commits, Lefthook and git-cliff

How I standardised commit messages and changelogs across a polyglot workspace — Conventional Commits enforced by a shared shell hook, Lefthook to manage the hooks, Keep a Changelog as the format, and git-cliff to generate the changelog from history and keep it current in CI

17 Jul 2026 · 13 min read · Stephen Masters devopsgitautomationchangelog

Over a couple of evenings I standardised the developer tooling across every repository in my workspace — this blog, Velostevie (my cycling blog), and a handful of client, QA and publishing projects. Eleven repos in five languages, each with its own idea of what a commit message should look like and none of them with a changelog worth reading.

The goal was maintainability: consistent commit history, a changelog in every project, and hooks that keep both honest without anyone having to remember they exist. This post is the pattern that came out of it — Conventional Commits enforced by a shared hook, Lefthook to install and run the hooks, Keep a Changelog as the format, and git-cliff to generate that changelog from the commit history. The payoff is a changelog you never write by hand and never forget to update.

The problem with changelogs

A CHANGELOG.md is the first file to fall out of date. It lives outside the code, nothing breaks when it is wrong, and updating it is a separate act of discipline from the work it describes. So it rots — or, more often, it never gets started, and “what changed in this release?” becomes an archaeology exercise across git log and merged PRs.

The information is already there. Every change is a commit, and a commit already has a message. The problem is that free-form commit messages aren’t structured enough to generate anything from. “Fixing up dependencies”, “Latest content”, “Typo correction” — I found all of these in my own history — tell a tool nothing about whether a change was a feature, a fix, or a chore, and nothing about what to put under which heading.

The fix is to treat the commit message as structured data: adopt a grammar strict enough that a tool can parse it, enforce that grammar at commit time, and then generate the changelog from the parsed history. Three moving parts:

  1. Conventional Commits — the grammar.
  2. A commit-msg hook (managed by Lefthook) — the enforcement.
  3. git-cliff — the generator that turns the history into a Keep a Changelog document.

This is the same instinct that runs through the rest of this blog: do the work at build time, not by hand, and not at the moment someone is trying to cut a release.

Conventional Commits: the grammar

Conventional Commits is a tiny specification for the first line of a commit message:

text
1
<type>[(scope)][!]: <description>

A type from a fixed set, an optional scope in parentheses, an optional ! to flag a breaking change, then a colon and a description. A few real examples from these repos:

text
1234
feat(nav): add mobile menu
fix: correct hero lede fallback
ci: lint with ruff instead of flake8
chore(release): 1.0.0

The types I settled on are the eleven from @commitlint/config-conventional — the most widely adopted ruleset, descended from Angular’s original commit convention: feat, fix, docs, style, refactor, perf, test, build, ci, chore and revert. That is enough vocabulary to describe any change without agonising over the boundary. feat and fix are the two that carry semantic weight — they map onto minor and patch version bumps — and everything else is bookkeeping.

The value only appears once the format is enforced. A convention that is merely documented is a convention that half your commits ignore, and half is worse than none because now your generator produces a changelog with holes in it.

Enforcing it with a dependency-free hook

The obvious tool here is commitlint, and if every repo were a Node project I would have used it. But only four of my eleven repos have a package.json; the rest are Python, or Go, or plain content. Adding a Node toolchain to a Python OCR pipeline purely to lint commit messages is the sort of incidental complexity that standardisation is supposed to remove, not create.

So the enforcement is a single POSIX sh script with no dependencies, identical in every repo. Here is the core of it:

.githooks/commit-msg
sh
123456789101112131415161718192021222324252627282930313233343536373839
#!/bin/sh
# commit-msg hook — enforce Conventional Commits across all projects.

msg_file="$1"
commit_source="$2"

# Skip auto-generated merge/squash messages.
case "$commit_source" in
  merge | squash) exit 0 ;;
esac

# Header = first non-comment, non-blank line.
header=$(grep -v '^#' "$msg_file" | sed '/^[[:space:]]*$/d' | head -n 1)

# Skip commit kinds that have their own conventions.
case "$header" in
  "Merge "* | "Revert "* | "fixup! "* | "squash! "*) exit 0 ;;
esac

types='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'

if printf '%s' "$header" | grep -Eq "^(${types})(\([a-z0-9._/-]+\))?(!)?: .+"; then
  # Description must not end with a full stop.
  desc=$(printf '%s' "$header" | sed -E 's/^[^:]+: //')
  case "$desc" in
    *.) echo "✖ commit-msg: no trailing full stop in the description" >&2; exit 1 ;;
  esac
else
  echo "✖ commit-msg: header must follow Conventional Commits:" >&2
  echo "  <type>[(scope)][!]: <description>" >&2
  echo "  allowed types: ${types}" >&2
  exit 1
fi

# Hard limit 100 characters on the header (72 preferred).
len=$(printf '%s' "$header" | wc -m | tr -d ' ')
[ "$len" -gt 100 ] && { echo "✖ commit-msg: header is $len chars; keep it ≤ 100" >&2; exit 1; }

exit 0

The real version also warns (without blocking) on headers over 72 characters and on body lines over 72, and enforces the blank line between header and body. But the shape above is the whole idea: a regex for the header, a check that the description doesn’t end in a full stop, and a length gate. Forty lines of shell, no node_modules, works the same whether the repo is Hugo or Python.

One honest limitation: a commit-msg hook runs locally only. Commits made through the GitHub web UI, and Dependabot’s automated commits, never touch it. That is a gap I accept — those commits are already well-formed (Dependabot writes build(deps): by default), and the alternative, a CI check that rejects PRs on message format, is more friction than it is worth for a personal workspace. If you are on a team, add that CI check; the same regex works there.

Lefthook: managing the hooks

A hook script in a file does nothing until Git knows to run it. The naïve approach is git config core.hooksPath .githooks, pointing Git at a committed directory. That works, and it is where I started, but it has a sharp edge I walked straight into.

The core.hooksPath trap

Git consults exactly one hooks path. If you set core.hooksPath, Git ignores .git/hooks entirely. So the moment you also want a proper hook manager — to run linters on staged files, a build on push, several commands per hook — you discover that the manager installs into .git/hooks, which Git is now ignoring because you told it to look at .githooks instead. The hooks silently never fire.

The resolution is to pick one owner. I moved to Lefthook and let it own .git/hooks, then had Lefthook invoke the shared commit-msg script as a command. You must unset core.hooksPath for this to work — the two mechanisms are mutually exclusive, not complementary.

One config, three hooks

Lefthook is a single Go binary configured by one YAML file. Here is the whole thing for this blog:

lefthook.yml
yaml
123456789101112131415161718192021222324
pre-commit:
  parallel: true
  commands:
    eslint:
      glob: "*.{js,mjs,cjs}"
      run: npx eslint {staged_files}
    stylelint:
      glob: "*.{css,scss,sass}"
      run: npx stylelint {staged_files}
    markdownlint:
      glob: "*.md"
      exclude:
        - CLAUDE.md
      run: npx markdownlint-cli2 {staged_files}

pre-push:
  commands:
    build:
      run: npm run build

commit-msg:
  commands:
    conventional:
      run: .githooks/commit-msg {1}

Three things worth drawing out:

  • pre-commit lints only staged files. The {staged_files} template, filtered by glob, means a commit that touches one SCSS file runs Stylelint on that file and nothing else. Commits stay fast, and — importantly — the linters don’t retroactively rewrite files you didn’t touch. Legacy code is left alone until you next edit it.
  • The heavy job goes on pre-push, not pre-commit. A full Hugo build takes seconds, not milliseconds. Running it on every commit would make committing painful; running it on push catches a broken build before it ever reaches CI, which is exactly when you want to know.
  • commit-msg just calls the shared script. Lefthook passes the message-file path through as {1}. The polyglot enforcement and the per-language linting live in the same config, but the Conventional Commits logic stays in the one portable shell script that every repo shares.

Installation is wired into whatever each ecosystem already runs. For the Node repos that is the prepare script, so npm install sets the hooks up with no extra step:

package.json
json
12345
{
  "scripts": {
    "prepare": "lefthook install || true"
  }
}

For the Python repos, brew install lefthook plus a line in the README covers it. The point is that a fresh clone plus the normal install command leaves you with working hooks and no manual “don’t forget to run lefthook install” ritual.

Keep a Changelog: the format

With commit messages now structured, the destination needs a shape too. Keep a Changelog is the de facto standard: a reverse-chronological list of released versions, each grouped under headings — Added, Changed, Fixed, Removed, Deprecated, Security — with a link to the compare view for each release. It reads like this:

CHANGELOG.md
markdown
1234567891011
## [1.0.0] - 2026-07-10

### Added

- Add Plausible analytics and fix npm audit vulnerabilities
- Add SEO improvements: meta tags, Open Graph, Twitter Cards, JSON-LD

### Changed

- Modernise tooling and image shortcode
- Document security scanning setup in CLAUDE.md

The reason to keep a curated file at all, rather than pointing people at git log, is that a changelog is written for humans deciding whether to upgrade, not for machines reconstructing history. It is grouped, de-noised, and phrased for a reader. The trick is to generate that human-readable document from machine-readable commits — which is exactly what git-cliff does.

git-cliff: generating the changelog

git-cliff is a changelog generator (written in Rust, distributed as a single binary) that parses your commit history according to Conventional Commits and renders it through a template. One cliff.toml per repo configures both the parsing and the output.

The template half emits the Keep a Changelog structure — the header, the per-release ## [version] - date blocks, and the compare-view links in the footer. The interesting half is the commit_parsers array, which maps commits onto the changelog’s section headings:

cliff.toml
toml
1234567891011121314
[git]
conventional_commits = true
filter_unconventional = false

commit_parsers = [
    { message = "^Merge ",   skip = true },
    { message = "^.*: add",  group = "Added" },
    { message = "^.*: remove", group = "Removed" },
    { message = "^fix",      group = "Fixed" },
    { message = "^.*: fix",  group = "Fixed" },
    { message = "^.*",       group = "Changed" },
]

sort_commits = "oldest"

Each commit is tested against the parsers in order and lands in the first group it matches. fix: commits become Fixed, anything mentioning “add” becomes Added, and the catch-all ^.* sweeps everything else into Changed. The { message = "^Merge ", skip = true } line at the top is doing real work: it drops the “Merge pull request #204…” commits that a PR-based workflow generates, which would otherwise be noise in every release.

Generating or updating the changelog is then one command:

bash
1
git cliff --output CHANGELOG.md

The baseline gotcha

The first time you run git-cliff on an existing repo, it parses the entire history back to the first commit — including all the pre-Conventional-Commits mess. My baseline changelogs came out with “Initial commit” and “Fixing up dependencies” duplicated across groups, because the heuristic parsers do their best with messages that predate the convention. That is fine for a one-time baseline: you accept a slightly untidy first entry, and everything from that point forward is clean because every new commit is conventional. If it bothers you, hand-edit the baseline once; it is a document, not a build artefact.

Closing the loop: automate it in CI

Everything so far still has one manual step: someone has to remember to run git cliff and commit the result before cutting a release. That is the weak link — the exact discipline that lets changelogs rot in the first place. So the last move is to take the human out of it and regenerate the changelog in CI, on merge to main.

git-cliff can compute the next version from the commits it sees (a feat implies a minor bump, a fix a patch, a ! a major) with --bump, so the whole thing is one action:

.github/workflows/release.yml
yaml
12345678910111213141516171819202122232425262728293031323334
name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write

jobs:
  changelog:
    # Don't retrigger on the release commit this job itself pushes.
    if: "!contains(github.event.head_commit.message, 'chore(release):')"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate changelog and compute next version
        id: cliff
        uses: orhun/git-cliff-action@v4
        with:
          args: --bump --output CHANGELOG.md

      - name: Commit, tag and push
        run: |
          version="${{ steps.cliff.outputs.version }}"
          git config user.name  "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add CHANGELOG.md
          git commit -m "chore(release): ${version#v}"
          git tag "$version"
          git push --follow-tags

On every merge to main, git-cliff regenerates the changelog, works out the next version from the commits, and the job commits the updated CHANGELOG.md, tags the release, and pushes. The if: guard is essential — without it the job’s own chore(release): commit would trigger the workflow again, and you would have an infinite loop of releases.

There is one wrinkle if main is branch-protected (mine is — it requires passing checks before anything merges). A protected branch won’t accept a direct push from CI, so instead of pushing to main you regenerate the changelog on the branch that is about to be merged. On this blog the flow is push to dev → CI opens a PR to main → merge; the natural home for the git-cliff step is on that dev push, so the changelog update travels into main as part of the reviewed PR rather than being forced in afterwards. Either way, the principle holds: the changelog is regenerated by a machine from structured commits, and no human is trusted to remember.

Teach your AI assistant the convention

One more place the convention should live: your AI coding assistant’s project instructions. If you use Claude Code, that is CLAUDE.md at the repo root; for GitHub Copilot it is .github/copilot-instructions.md. The assistant reads the file at the start of every session, so a convention written there is one the assistant follows without being told — including when it writes your commit messages for you.

The section I added to every repo is short:

CLAUDE.md
markdown
1234567891011121314
## Commit messages

All commits follow Conventional Commits, enforced by a commit-msg hook:

    <type>[(scope)][!]: <description>

- Types: feat, fix, docs, style, refactor, perf, test, build, ci,
  chore, revert.
- Description in the imperative mood, no trailing full stop; header
  72 characters or fewer.
- The type drives the changelog and the version bump (fix → patch,
  feat → minor, ! → major) — choose it for what the change means to
  a reader, not for what is convenient.
- Never bypass the hooks with --no-verify.

Two things make this worth the five minutes. First, it dissolves the main objection to adopting a commit grammar — that writing to a format is friction — because the assistant drafts a well-formed message from the staged diff and you just review it. Second, the note about types driving the version bump matters more than it looks: once git-cliff derives semver from your commits, the choice between fix and chore is a versioning decision, and the assistant should know that when it proposes one.

The instructions and the hook are complementary, not redundant: the instructions file makes the assistant produce conforming messages, and the hook remains the guarantee for every commit regardless of who — or what — wrote it. Guidance in the instructions, enforcement in the hook.

What you actually get

Put together, the pieces reinforce each other:

  • Conventional Commits give every change a machine-readable shape.
  • The shared commit-msg hook makes that shape non-optional, in one portable script that doesn’t care what language the repo is written in — and writing to the format is barely any work now that an AI coding assistant such as Claude Code will draft a well-formed Conventional Commit straight from the staged diff, so conforming costs essentially nothing at the keyboard.
  • Lefthook installs and runs that hook — plus staged-file linting and a pre-push build — from one config, on a plain install.
  • Keep a Changelog gives the output a human-readable structure.
  • git-cliff generates that structure from the history, and CI keeps it current on every merge.

The result across eleven repos is that commit messages are consistent enough to be useful, every project has a changelog, and — the part that actually matters — none of it depends on anyone remembering to do it. The discipline lives in the tooling, which is the only place discipline reliably survives.

If you adopt one piece, make it the commit convention: it is the input everything else feeds on, and it costs nothing but a forty-line hook.

SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.