One clang-format version everywhere: pre-commit, CI and your laptop¶
A pull request fails the format check. The author runs clang-format -i locally, pushes, and it
fails again with a different diff. Nothing is wrong with the code. The laptop has clang-format 18,
CI installed 21 from a package repository last week, and the reviewer's editor plugin ships 19.
Every major LLVM release changes clang-format's output in small ways: new style options, changed
defaults, fixed bugs. clang-tidy is worse, because new checks appear and old ones move between
categories. Pinning the project version of a linter wrapper does not help if the wrapper pulls
whatever clang happens to be around.
The cpp-linter tools are built to pick one LLVM major version and use it in every place code gets checked.
Pick the version once¶
Write it down somewhere visible, for example in CONTRIBUTING.md:
Formatting and static analysis use LLVM 21 (clang-format 21, clang-tidy 21).
Everything below pins 21. Pick a major every tool has: the clang-tidy hook covers LLVM 13 to 22,
the action and clang-tools 12 to 23.
Pre-commit: cpp-linter-hooks¶
cpp-linter-hooks installs clang-format and
clang-tidy as Python wheels, so every contributor gets the same release regardless of what their
distribution ships. The rev is the hooks release; the tool version is a separate --version
argument:
repos:
- repo: https://github.com/cpp-linter/cpp-linter-hooks
rev: v1.6.0
hooks:
- id: clang-format
args: [--style=file, --version=21]
- id: clang-tidy
args: [--version=21]
21 means the newest 21.x wheel on PyPI, looked up on each run, so the two hooks can land on
different patch releases of 21. --style=file reads .clang-format, and clang-tidy finds
.clang-tidy by itself: the hooks read the same files CI will use, so the rules, like the version,
are defined in one place. The clang-tidy hook needs a compile_commands.json; if your build does
not produce one before commit time, run clang-tidy only in CI.
CI: cpp-linter-action¶
cpp-linter-action installs the requested
version itself; the workflow does not need apt-get install clang-format-21 or a matching LLVM
apt repository. The version input takes only the LLVM major version. It also accepts a path to
tools you installed yourself, or an empty string for whatever the runner has; the
input reference has the
details.
name: cpp-linter
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
cpp-linter:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: cpp-linter/cpp-linter-action@v2
id: linter
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
version: '21'
style: file
tidy-checks: ''
thread-comments: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && 'update' }}
- name: Fail on lint errors
if: steps.linter.outputs.checks-failed > 0
run: exit 1
style: file and an empty tidy-checks tell the action to use .clang-format and
.clang-tidy from the repository, the same files pre-commit used a minute earlier on the
developer's machine. The result is file annotations in the diff view, plus a thread comment on the
pull request that is edited on each push while there are findings and removed once they are fixed.
Pull requests from forks get a read-only token, and posting the comment would fail the step, so
the condition leaves the comment off for them.
Local one-off runs: clang-tools¶
Sometimes you want the binary itself: a quick clang-format --dry-run over a few files, or a
clang-tidy run with a hand-written compile database. clang-tools
downloads the static binary for that LLVM major (21.1.0 for 21) unless a clang-format-21 is
already on your PATH, and falls back to the PyPI wheel if the download fails. In a virtualenv:
pip install clang-tools
clang-tools install clang-format clang-tidy --version 21
clang-format-21 --version
The binaries come from clang-tools-static-binaries,
which publishes a rolling window of recent LLVM majors for Linux, macOS and Windows, on x86-64 and
ARM64; each release lists its exact versions in
versions.json.
cpp-linter-action uses the runner's package manager where it can: apt on Linux, Homebrew's
llvm@21 on macOS, these binaries on Windows. Every place runs clang-format 21, but not always the
same patch release.
Containers and other package managers¶
Docker images carry Ubuntu's clang-format and clang-tidy packages, tagged by major version up to 22:
docker run --rm -v "$PWD":/src xianpengshen/clang-tools:21 clang-format --dry-run --Werror /src/main.cpp
On macOS, the Homebrew tap (LLVM 19 to 23) installs the same static binaries; the asdf plugin does so on every platform, for teams that already manage tool versions that way:
brew install cpp-linter/tap/clang-format@21 cpp-linter/tap/clang-tidy@21
Upgrading¶
When LLVM 22 is the version you want, change it everywhere in one commit:
- Change
21to22in.pre-commit-config.yaml, the workflow andCONTRIBUTING.md. - Run
pre-commit run clang-format --all-filesand commit the reformatted tree together with the version change. - Open the pull request. cpp-linter-action runs with 22 and should report nothing, because the tree was formatted with the same major version a moment ago.
If the action does report differences, compare the exact releases: the action's log prints the one
it ran (clang-format-22 --version), and the hook uses the newest 22.x wheel. clang-tidy drifts
more than clang-format: a new major adds, renames and tightens checks, so a pull request that
passes locally and fails in CI with a check nobody has heard of usually means the two version
numbers above differ.