make preflight — the local gate that matches CI¶
make preflight # everything, on the files you changed
scripts/dev/preflight.sh --full # everything, on the whole tree
scripts/dev/preflight.sh --stage clang # just one stage
scripts/dev/preflight.sh --list # which CI context each stage mirrors
Run it before you push. make lint and make test-fast build with one compiler; CI builds with several, and the gap is where portability bugs live.
Why it exists¶
Each portability break below was green under local gcc and cost a full CI round-trip. Because only one PR is in flight at a time, that is queue time for every other PR too: on 2026-09-07 a single branch shipped the first three breaks in one afternoon and held the merge window for about three hours.
| # | What was written | Locally | In CI |
|---|---|---|---|
| 1 | static_assert(UINT_MAX <= SIZE_MAX/2/sizeof(ptr)) | fine on 64-bit | the i686 lane of the time could not compile it (that lane is retired: the fork is 64-bit only, ADR-1258) |
| 2 | __declspec(align((x))) | gcc never compiles the MSVC branch | Windows MSVC+CUDA → C2059 on every use; align() needs a literal |
| 3 | __attribute__(noinline) | gcc accepts the single paren | Ubuntu clang, clang+DNN and four Sanitizer lanes fail to compile |
| 4 | nullptr in a .c file | gcc and clang accept it under -std=c23 | Windows MSVC+CUDA → C2065 at every site; ADR-1138 keeps C TUs on NULL |
| 5 | static const double used in a static aggregate initialiser | no diagnostic at all, even with -pedantic-errors -Weverything | Windows MSVC+CUDA → C2099, then cascading C2440s as the members shift |
| 6 | M_PI in a new test file | glibc exposes it because meson passes -D_GNU_SOURCE | the MinGW lane (now Windows UCRT64) → 'M_PI' undeclared; MinGW ignores _GNU_SOURCE and -std=c23 sets __STRICT_ANSI__ |
| 7 | const ulong half = ... in a .metal shader | the C / CUDA / HIP twins it was ported from have no such reserved name | macOS Clang+Metal → cannot combine with previous 'type-name' declaration specifier; half is MSL's 16-bit float type |
Rows 5 to 7 are from the same evening. PR #1340's new test file carried rows 5 and 6 (21 errors on Windows MSVC+CUDA), and PR #1342's carried row 7. Row 5 arrived on the same branch as row 4.
All are new files added by a PR, which is the pattern: fresh code is where portable-looking constructs that exactly one lane rejects get written.
Stages¶
| Stage | Mirrors | Catches |
|---|---|---|
gcc | Ubuntu gcc(+DNN) | the baseline build and fast tests |
clang | Ubuntu clang(+DNN) | clang-only syntax; gcc is far more permissive about attributes and extensions |
msvcism | Windows MSVC+CUDA / +SYCL | constructs MSVC rejects, checked statically so no MSVC is needed |
sanitizers | Sanitizers (address) / (undefined) | UB the plain build hides (the required Sanitizers (thread) context is CI-only) |
tidy | Tidy Changed | clang-tidy on the touched files, using the workflow's own exclusion list |
cppcheck | Cppcheck | cppcheck's findings |
A stage whose toolchain is missing is skipped with a notice, not failed, so the script is still useful on a partially provisioned machine.
There is no 32-bit stage. The fork is 64-bit only, and the m32 stage went with the i686 lane it mirrored (ADR-1258).
Behaviours worth knowing¶
It looks at uncommitted work. Not just origin/master...HEAD — the edit you are about to commit is exactly what you want checked. (The first version of this script did not, and cheerfully passed against a deliberately planted break.)
sanitizers needs -Db_lundef=false. Clang links the sanitizer runtime into executables, not shared libraries, so libvmaf.so is left with undefined __asan_report_* / __ubsan_handle_* symbols and -Wl,--no-undefined refuses the link. The repo's own fuzz.yml pairs the same two options. Without it the stage fails on every branch, including ones that change no code at all — and a stage that cries wolf is a stage people learn to ignore.
What it does not cover¶
Windows UCRT64, the Windows MSVC lanes proper, and Ubuntu HIP have no local equivalent here. msvcism is a set of pattern matches plus two small scanners over known rejection classes, not a compiler — Windows MSVC+* remains the authority. scripts/dev/find-nonconst-static-init.py finds a static aggregate initialised from a const object (MSVC C2099). scripts/dev/find-posix-only-headers.py finds a POSIX-only header (<unistd.h>, <dlfcn.h>, <sys/socket.h>, ...) outside a platform conditional in a source the Windows build compiles (MSVC C1083); it reads the meson.build files to skip sources kept off Windows (host_machine.system() != 'windows'), and a scan that cannot run fails the stage. A file that is POSIX-only by design and built by no meson.build is a named exception in .config/lint-exceptions.d/msvcism-posix-headers.toml (ADR-2646). For HIP and the other GPU backends use the dev container (dev-mcp.md, ADR-0451).
Keeping it honest¶
If you add a required CI context, add a stage or record why it cannot be mirrored. --list is the single place that mapping is written down, and ADR-1234 records the reasoning.