ADR-1234: The local gate builds with every compiler CI does¶
- Status: Accepted
- Date: 2026-09-07
- Deciders: Lusoris
- Tags: ci, build, agents
Context¶
The documented local gate — make lint, meson test -C build — builds with one compiler. CI builds with several: Ubuntu gcc+DNN, Ubuntu clang+DNN, Ubuntu i686 gcc, Windows MSVC+CUDA, Windows MSVC+SYCL, Windows MinGW64, and four Sanitizer lanes. A change can be green locally and red in three of those at once.
That is not hypothetical. On 2026-09-07 one branch shipped three separate portability breaks in a single afternoon, each green under local gcc:
| Break | Locally | CI |
|---|---|---|
static_assert(UINT_MAX <= SIZE_MAX/2/sizeof(ptr)) | passes on LP64 | Ubuntu i686 gcc cannot compile the file — false on 32-bit |
#define ALIGNED(x) __declspec(align((x))) | gcc never sees the MSVC branch | Windows MSVC+CUDA C2059 on every use — MSVC needs a literal |
__attribute__(noinline) (one paren lost) | gcc accepts it | Ubuntu clang, clang+DNN and all four Sanitizer lanes fail to compile |
nullptr in a C translation unit | gcc and clang accept the C23 keyword | Windows MSVC+CUDA C2065 — MSVC's /std:clatest does not implement it (ADR-1138) |
static const double initialising a static aggregate | silent under -std=c23 -pedantic-errors -Weverything | Windows MSVC+CUDA C2099, then a cascade of C2440s as the remaining initialisers shift |
M_PI without the _USE_MATH_DEFINES / #ifndef two-step | glibc exposes it via meson's -D_GNU_SOURCE | Windows MinGW64 'M_PI' undeclared — MinGW ignores _GNU_SOURCE, and -std=c23 defines __STRICT_ANSI__ |
A .metal local named half | fine in the C / CUDA / HIP twins the kernel was ported from | macOS Clang+Metal redeclaration error — half is a built-in MSL type, and every later use then parses as a type name |
The last two rows arrived the same evening in PR #1340, both in one new test file, and are the reason the msvcism stage grew a scanner alongside its grep patterns: neither is expressible as a line-local regex, and no locally available compiler diagnoses the second one at any warning level.
Each cost a full CI round-trip on a queue where one PR at a time may be in flight (the strict single-active-PR rule). Three round-trips is roughly ninety minutes of queue time, and the branch held the merge window for about three hours in total. Every one of the three was catchable in seconds on the developer machine.
The common cause was also identifiable: each came from generalising a fix by regex across a file rather than editing only the sites the checker named. A local gate that compiles what CI compiles turns that class of mistake from a CI round-trip into an immediate local failure.
Decision¶
Add scripts/dev/preflight.sh, run as make preflight, which reproduces the CI lanes that are reproducible on Linux and statically approximates the one that is not:
| Stage | Mirrors | Catches |
|---|---|---|
gcc | Ubuntu gcc(+DNN) | the baseline build |
clang | Ubuntu clang(+DNN) | clang-only syntax, e.g. __attribute__(x) |
m32 | Ubuntu i686 gcc | 32-bit-only static_assert / width assumptions |
msvcism | Windows MSVC+CUDA / +SYCL | constructs MSVC rejects, without needing MSVC |
sanitizers | Sanitizers (address/undefined) | UB the plain build hides |
tidy | Tidy Changed | clang-tidy on the touched files, with the workflow's exclusion list |
cppcheck | Cppcheck | cppcheck's findings |
Two properties are deliberate. It considers uncommitted work, not just origin/master...HEAD — a preflight that ignored the edit you are about to commit would miss the point (the first version did exactly that and passed against a planted break). And a missing toolchain skips its stage with a notice instead of failing, so the script stays useful on a partially provisioned machine.
The m32 stage compiles with -fsyntax-only and -I build/src, because a full 32-bit build needs multilib runtime libraries while the failure mode it exists for — an assumption about the width of size_t / ptrdiff_t — is visible at parse time. The include path is load-bearing: without the generated config.h the compile aborts at the first #include and the stage silently becomes a no-op, which is how the first version of it "passed" against a deliberately planted 32-bit break.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| Local multi-compiler preflight (chosen) | Catches the real failures in seconds; no CI capacity used; works offline | Another script to keep in step with the workflow matrix | Chosen — validated against all three real breaks |
| Rely on CI | Nothing to maintain | Each miss costs a round-trip, and with one active PR that is queue time for everyone | This is the status quo that produced the three-hour block |
| Run the full matrix in the dev container | Closest to CI, includes HIP/SYCL | Minutes per run; too slow to sit in front of every commit | Kept for backend work; too heavy as a default |
| Cross-compile for Windows with MinGW | Real Windows compile | MinGW is not MSVC and misses __declspec differences; toolchain not installed | The static check covers the actual failure class more cheaply |
| Add more CI lanes | Central enforcement | Makes the queue slower, which is the problem being solved | Wrong direction |
Consequences¶
- Positive: the three failure classes that cost round-trips on 2026-09-07 now fail locally in seconds. Verified by planting each break and watching the matching stage fail, then reverting.
- Positive:
--stage NAMEmakes it cheap to re-run just the lane that failed, and--listdocuments which CI context each stage stands in for. - Negative: the stage list must be kept in step with the workflow matrix.
--listis the single place that mapping is written down. - Negative:
msvcismis a set of pattern matches plus one scanner, not a compiler. It catches the known rejection classes, not everything MSVC dislikes;Windows MSVC+*remains the authority. - Neutral / follow-ups:
Ubuntu HIP,Windows MinGW64and the Windows MSVC lanes have no local equivalent here. The dev container (ADR-0451) covers HIP; the rest stay CI-only.