Skip to content

ci: clang-format reports instead of failing (fixes #89); gate the three convention checkers - #156

Merged
balbasty merged 2 commits into
mainfrom
ci/clang-format-informational-and-convention-gates
Aug 20, 2026
Merged

ci: clang-format reports instead of failing (fixes #89); gate the three convention checkers#156
balbasty merged 2 commits into
mainfrom
ci/clang-format-informational-and-convention-gates

Conversation

@balbasty

Copy link
Copy Markdown
Collaborator

Two CI-hygiene changes, one commit each. Neither touches any C++ source, so the
59,886 / 13 / 0 gate cannot move — confirmed anyway, see Validation.


1. lint (clang-format, changed lines) is informational — and can finally report

Per the owner on #145: "clang-format should be run for information alone, not
make the CI fail (we know we are not linted)."

It was red and silent, which is the worst combination. Two separate
problems, fixed together because the second is why the first mattered:

The reporting bug (#89). The step runs under set -euo pipefail and does
out=$(git-clang-format-18 ... --diff "$base"). But git-clang-format --diff
exits 1 by contract whenever it would reformat — that is how it says "here
is a diff", not an error. Under set -e a failing command substitution in an
assignment aborts the shell, so the whole case that prints $out was
unreachable in exactly the situation it exists for. Every finding rendered as
a bare exit code: no diff, no annotation, no Run: git clang-format … hint.
The clean path reached the report normally, which is why it survived. The exit
status is now captured rather than fatal.

The policy.continue-on-error: true meant it did not block, but it still
put a red ✗ on essentially every PR, which trains everyone to skim past the
whole checks list. Findings now always exit 0. When there is a diff the job
prints it in full in the log, emits a ::notice:: annotation, and writes a
$GITHUB_STEP_SUMMARY block with the file list and the diff.

Three details worth flagging for review:

  • continue-on-error is dropped, deliberately. It was doing two jobs and
    only one was wanted: it stopped findings from blocking a merge (now handled
    properly, by not failing) and it swallowed infrastructure failure, so a
    job whose apt install or checkout died looked the same as one that merely
    found unformatted lines. Splitting them is the point — findings are always
    green, while a lint that cannot run at all is a real defect and is allowed to
    go red, so it cannot rot unnoticed the way the reporting path just did.
  • An exit status above 1 is a genuine tool failure (bad arguments, missing
    binary) and is now told apart from a finding, with a ::warning::, instead of
    being reported as an empty "clean". Still exits 0.
  • The summary diff is capped at 400 linesGITHUB_STEP_SUMMARY is dropped
    wholesale past 1 MiB, and a truncated summary that renders beats an oversized
    one that vanishes. The job log always has it in full.

Also corrects .clang-format's pointer to .github/workflows/lint.yaml, gone
since the consolidation.

2. The three convention checkers now actually run

CLAUDE.md documents tools/normalise-include-delimiters.py,
tools/normalise-header-guards.py and tools/rename-macros.py as enforcing
their conventions via --check. No workflow invoked any of them. All three
pass on main today, so this was latent rather than broken — the harder kind
to notice, because nothing goes red until long after the convention has rotted.
Each was applied across 100+ files in one sweep (#145, #146, #91), and the
drift they guard against (an unprefixed macro in an installed header taking a
name from every downstream TU, a stray #ifndef, a mis-delimited include) is
invisible to every compile and test gate here.

These genuinely fail the build, unlike clang-format. That one reports a
style opinion about a tree predating the style file; these encode decisions
already taken and already applied everywhere, so a violation is a defect.

A new lint (source conventions) job rather than extra steps on lint (cuda launch sites) — the one judgement call here, happy to be overruled. They are
the same shape (fast, stdlib-Python, whole-tree textual invariants), but #154
built that job's name and its comment block around one specific rule, and a red
tick reading "cuda launch sites" for a header-guard violation would misdirect.
It also avoids renaming a check that landed two commits ago. Each checker is
its own step so the failing tick names the convention, and the later steps carry
if: !cancelled() so a change tripping two of them hears about both in one run
rather than one per push.

The job installs nothing — no apt, not even setup-python; all three are
pure stdlib and just read files, so it uses the runner image's interpreter.
Four jobs were killed at their timeout-minutes inside apt-get during the
2026-08-19 mirror stall while test-hub rode it out precisely because it
installs nothing.


Validation

The clang-format reporting path, against a deliberately mis-formatted file
which is precisely what nobody did before, and why #89 existed. Run with the
step body extracted verbatim from the committed YAML:

pathold stepnew step
findingsexit 1, nothing printedexit 0 + full diff + ::notice:: + summary
cleanexit 0exit 0
tool failure (exit 3)exit 1, silentexit 0 + ::warning::
618-line diffexit 0, summary truncated at 400, full diff in log

Each convention checker still fires, the same way #154 proved its lint does
with a rogue launch. A violation of each was injected into
include/fastfields/api/distance.h in turn:

injectedcheckerresult
#include "fastfields/core/dlpack.h"normalise-include-delimitersexit 1, names file:line
whole-file #ifndef FF_API_DISTANCE_Hnormalise-header-guardsexit 1, names file
#define SNEAKY_UNPREFIXED_MACRO 1rename-macrosexit 1, names file:line

All three pass on this branch, codespell is clean, the workflow parses, and
every run: step in the file is valid bash.

Gate:tools/test-baseline.sh --legs default,lib --check tools/test-baseline.expected — unchanged. The diff contains zero C++
source files (.github/workflows/ci.yml, CLAUDE.md, .clang-format only), so
it is structurally impossible for it to move.

Closes#89.


Generated by Claude Code

Two problems, one job.
The reporting bug (#89). The step runs under `set -euo pipefail` and captures
the diff with `out=$(git-clang-format-18 ... --diff "$base")`. But
`git-clang-format --diff` exits 1 BY CONTRACT whenever it would reformat
something -- that is how it says "here is a diff", not an error. Under `set -e`
a failing command substitution in an assignment aborts the shell, so the entire
`case` that prints `$out` was unreachable in exactly the situation it exists
for. Every finding rendered as a bare exit code: no diff, no annotation, no
"Run: git clang-format ..." hint. The clean path reached the report normally,
which is why this survived so long. The status is now captured instead of being
fatal, and an exit above 1 (a real tool failure) is told apart from a finding
rather than being reported as an empty "clean".
The policy. The job was `continue-on-error: true`, so it did not block -- but it
still put a red mark on essentially every PR, which trains people to skim past
the whole checks list. Findings are now reported and the step exits 0, always.
When there is a diff it prints it in full in the log, emits a `::notice::`
annotation, and writes a step-summary block with the file list and the diff
(capped at 400 lines, since GITHUB_STEP_SUMMARY is dropped wholesale past
1 MiB). Being informational is only useful if the information is reachable.
`continue-on-error` is dropped, because it was doing two jobs and only one was
wanted. It stopped findings from blocking a merge -- now handled properly, by
not failing -- and it also swallowed infrastructure failure, so a job whose apt
install or checkout died looked the same as one that merely found unformatted
lines. Splitting them is the point: findings are always green, while a lint
that cannot run at all is a real defect and is allowed to go red, so it cannot
rot unnoticed the way the reporting path just did.
Verified against a deliberately mis-formatted file, which is precisely what
nobody did before: the findings path prints the diff, annotates and exits 0;
the clean path and the tool-failure path both exit 0 too.
Also corrects .clang-format's pointer to `.github/workflows/lint.yaml`, which
has not existed since the consolidation.
Closes#89
CLAUDE.md documents each of `tools/normalise-include-delimiters.py`,
`tools/normalise-header-guards.py` and `tools/rename-macros.py` as enforcing
its convention via `--check`. No workflow invoked any of them, so that
guarantee was fictional. All three pass on `main` today, which makes this
latent rather than broken -- and latent is the harder kind to notice, because
nothing goes red until long after the convention has already rotted.
Each of these conventions was applied across 100+ files in a single sweep
(#145, #146, #91). Re-doing such a sweep because it drifted back costs far more
than running three scripts on every push, and the drift they guard against --
an unprefixed macro in an installed header taking a name from every downstream
translation unit, a stray `#ifndef` guard, a mis-delimited include -- is
invisible to every compile and test gate in this repo.
A new `lint (source conventions)` job rather than extra steps on `lint (cuda
launch sites)`. They are the same shape (fast, stdlib-Python, whole-tree
textual invariants), but #154 built that job's name and its comment around one
specific rule, and a red tick reading "cuda launch sites" for a header-guard
violation would misdirect. Keeping them separate also avoids renaming a check
that landed two commits ago. Each checker is its own step, so the failing tick
names the convention, and the later steps carry `if: !cancelled()` so a change
tripping two of them hears about both in one run instead of one per push.
These DO fail the build, unlike the clang-format job. That one reports a style
opinion about a tree predating the style file; these encode decisions already
taken and already applied everywhere, so a violation is a defect.
The job installs nothing -- no apt, not even setup-python -- and uses the
runner image's interpreter. All three are pure stdlib and read files. Four jobs
were killed at their timeout-minutes inside `apt-get` during the 2026-08-19
mirror stall while `test-hub` rode it out precisely because it installs
nothing; a gate that runs in seconds should not have a package mirror on its
critical path.
Verified each checker still fires by injecting a violation of each into
`include/fastfields/api/distance.h` in turn -- a quoted public include, a
whole-file `#ifndef` guard, and an unprefixed `#define` -- and confirming exit
1 with a message naming the file and line, the same way #154 proved its lint
fires with a rogue launch.
@balbastyClaude

Copy link
Copy Markdown
CollaboratorAuthor

Both behaviours verified against real Actions, not just locally

I put up a throwaway PR (#157, now closed, branch deleted) based on this branch rather than main, adding one file at a path matching no entry in the changes filter — so it cost only the lint jobs, no test-cpu and no build-cuda. tests/format_probe.cpp carried two deliberate defects at once.

Run 32417649104:

lint (clang-format, changed lines) → success, having printed the whole diff and this annotation:

##[notice]clang-format would reformat lines this PR touches in 1 file(s).
Informational only -- this job does not fail. To apply: git clang-format 8399f13...

Green while reporting findings is the whole point, and is exactly the case that was red-and-silent under #89.

lint (source conventions) → failure, as intended:

INCLUDES NOT MATCHING THE CONVENTION:
tests/format_probe.cpp:7: fastfields/core/defines.h -- public header, must use <>
##[error]Process completed with exit code 1

Two things that log also settles:

  • if: !cancelled() works. After normalise-include-delimiters failed, normalise-header-guardsandrename-macros both still ran and reported clean. A change tripping two conventions hears about both in one run rather than one per push.
  • The job took ~3 seconds wall clock (21:06:14 → 21:06:17), with no apt and no setup-python — the runner image's own python3.

Locally I also exercised the two paths a probe PR cannot easily produce, running the step body extracted verbatim from the committed YAML: a tool failure (binary exiting 3 → ::warning::, exit 0) and a 618-line diff (summary truncated at 400 with a pointer to the log, exit 0). And each of the three checkers was failed in turn by injecting its own violation into include/fastfields/api/distance.h — a quoted public include, a whole-file #ifndef guard, and an unprefixed #define — each exiting 1 and naming file and line.

The gate is untouched

This PR touches .github/workflows/, which by design triggers everything, so this PR's own CI is the gate run — and a more thorough one than the local --legs default,lib check. The clang-static leg on run 32417591419 reports:

distance 2352distance_mesh 4622distance_spline 704posdef 4012pushpull 308
pushpull_backward 6381reg_field 19250reg_flow 16347reg_op 186resize 630
restrict 65solve_field 452splinc 4577

13 suites, 59,886 checks, 0 failures — the expected number exactly. All five test-cpu legs, sanitize, tsan, test-hub and compile-probe-cuda are green; the two build-cuda legs are still running. Consistent with the diff containing zero C++ source files: only .github/workflows/ci.yml, CLAUDE.md and .clang-format.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: the clang-format job can never print its findings (set -e kills it before the report)

1 participant

@balbasty