Use the checked-out configuration as the source of truth. .gitlab-ci.yml defines stages and includes; ci/*.gitlab-ci.yml and ci/scripts/ define the actual jobs. Default MR pipelines run a limited smoke matrix; labels such as affected-tests, all-tests and gpu-tests, plus scheduled or manually started pipelines, enable broader jobs, and affected-tests composes with the *-tests platform labels and all-platforms to pick where it runs. A green default MR pipeline is not proof that every supported configuration was exercised.
A pipeline is evidence only for the commit it ran on: after a push, amend, or rebase, check which SHA the pipeline and the merge request point at before citing either — a green run on a superseded revision proves nothing about the current head, and a reported failure should be reproduced at the current head too.
Build jobs publish the configured build directory as an artifact. Their paired test jobs consume that artifact and run CTest without rebuilding. When changing either side, keep the test job's needs, CTest label or filter, and the corresponding build target consistent; otherwise CTest can discover tests whose executables are absent.
Publishing is opt-in per job rather than inherited: .common:linux:cross and .common:windows carry no artifacts: key, and a job picks up .artifacts:linux:builddir, .artifacts:windows:builddir or .artifacts:test:results as a second extends: parent. A test job takes the results template — it links nothing, so re-publishing the build directory it just downloaded would only duplicate the build job‘s artifact — and that template also registers JUnitTestResults_*.xml through artifacts:reports:junit:, which is what puts failures in the job’s Tests tab and the merge request widget rather than only in the log. A job that needs neither, such as test:linux:buildsystem, extends the base alone and publishes nothing.
Two things to know when reading a test report. A job that failed and then passed on retry still reports the failed first attempt: the retry runs --rerun-failed without -T test, so it never rewrites the dashboard Test.xml the report is converted from. That job exits 42 and is a soft warning, so a green pipeline showing a failed test is the flake being surfaced, not a regression. And the widget's comparison needs a base-branch report for the same job name; default-branch pushes run only a small subset of jobs, so most jobs show a summary without one.
In merge-request pipelines the Linux test jobs also keep a content-addressed pass cache (a per-job-name GitLab cache holding .testcache/): test.linux.script.sh skips tests whose executable, emulator, CTest definition, and environment fingerprint (image, lib* package state, ci/scripts/ and ci/docker/, and behavior-affecting variables such as EIGEN_REPEAT and QEMU_CPU) match a first-attempt pass recorded by an earlier MR pipeline, then records this run‘s first-attempt passes — taken from the dashboard run’s Test.xml statuses — via test_cache.py. Scheduled and web pipelines always run their full selection (fresh clock-derived RNG seeds are part of their coverage), sharded jobs never skip, and EIGEN_CI_TEST_CACHE: "off" opts a job out. Skipped tests are absent from that run's JUnit report, so a test count that falls between pipelines is expected rather than a regression, and a test job whose binaries all match cached passes legitimately reports “No tests were found”.
The fingerprint covers ci/scripts/ and ci/docker/, not the ci/*.gitlab-ci.yml files: everything in the YAML that reaches a test‘s outcome already reaches the key by value — job variables through KEYED_ENV_PREFIXES, the image through CI_JOB_IMAGE, compiler flags and the cross emulator through the digests of the files in the test’s command, CTest timeouts through the properties hash — so hashing the YAML as well only meant that every CI-maintenance merge request discarded every job‘s manifest. Two consequences follow. **A new job variable that can change a test’s outcome must be added to KEYED_ENV_PREFIXES**; setting it in the YAML alone no longer keys it. And a job's tags: are now invisible to the fingerprint, so moving a job to a runner pool whose CPU differs should be paired with a cache clear — though nothing distinguished two hosts within one tag pool before this either.
Three tiers, in increasing cost:
| Tier | Trigger | What runs |
|---|---|---|
| smoke | every MR with neither label below | the fixed list in cmake/EigenSmokeTestList.cmake, usually one part per test, at baseline ISA on x86-64, aarch64 and riscv64, under gcc and clang |
| affected | affected-tests label | every test the diff can reach, all parts, on x86-64 (gcc AVX2, clang baseline) and aarch64 (gcc, clang), plus any platform the diff or a *-tests label selects |
| full | all-tests label | the whole suite across the entire compiler and ISA matrix, minus the schedule-only jobs below |
One configuration sits outside all three tiers and runs only on schedules and web pipelines: the NVHPC (nvc++) build and test pair. Its frontend is slow enough that those two builds alone accounted for roughly a quarter of the project's hosted-runner minutes while they were in the all-tests matrix. Start a web pipeline when a change plausibly affects nvc++ rather than waiting for the scheduled run to find it.
The affected tier exists because the smoke list samples: it is broad but shallow, so a change confined to one module gets only the one part of each related test that the list happens to name. Reach for affected-tests when a change is module-local and you want depth without paying for the full matrix.
The tiers do not stack: affected-tests and all-tests each suppress the smoke jobs (.rules:libeigen:smoketest), because both go deeper than the fixed list on the same native runners and the smoke jobs would only pay for it twice. The affected tier‘s four unconditional jobs therefore mirror the smoke matrix’s compilers — gcc-10 and clang-14 on x86-64 and aarch64. Two smoke configurations are still not reproduced, and both need a label to get back: riscv64, whose native runner is a single scarce machine (rvv-tests or all-platforms), and x86-64 gcc at baseline ISA, since the unconditional gcc job is AVX2 (sse-tests or all-platforms). The suppression is scoped to the libeigen namespace, since neither wider tier has any job in a fork.
scripts/affected_tests.py computes the selection in the select:tests job and writes affected/targets.txt and affected/ctest_regex.txt, which the paired build and test jobs on both Linux and Windows consume through EIGEN_CI_BUILD_TARGET_FILE and EIGEN_CI_CTEST_REGEX_FILE. Run it locally the same way CI does:
python3 scripts/affected_tests.py --base-sha $(git merge-base origin/master HEAD) python3 scripts/test_affected_tests.py # unit tests, also run by checkformat:scripts python3 ci/scripts/test_test_cache.py # pass-cache unit tests, same job
checkformat:scripts runs both suites on every merge request and is blocking: both scripts fail closed, but a wrong answer is silent — a job that skips too much still reports success.
Selection follows the textual #include graph, ignoring preprocessor guards, so it is a strict superset of the real compile dependency and never drops an affected test. Because Eigen is header-only and the umbrella headers are hubs, a change under Eigen/src/Core typically reaches every test and the selector degrades to the full suite — that is the correct answer, not a failure. Changes to CMake, ci/scripts/, ci/docker/, or the BLAS/LAPACK shims also force the full suite, since they invalidate the mapping itself; the ci/*.gitlab-ci.yml files are orchestration and cannot change which test includes which header, so they select nothing. Git rename detection is disabled for the input diff so both the old and new path of a move are evaluated; an old path absent from the current graph safely forces the full suite.
The selector derives source-to-target mappings from test CMake registration, including multi-translation-unit executables and the GPU tests, whose sources are .cu because ei_add_test takes the extension from EIGEN_ADD_TEST_FILENAME_EXTENSION. A changed test source without a registration is an error rather than an unconfigured target to drop. test/buildsystem/ is skipped: its consumers are separate CMake projects that only test:linux:buildsystem configures, so an add_executable there is not a registration and its sources reach no test here. Targets absent from one configuration (optional dependencies such as CHOLMOD, CUDA or SYCL) are still filtered against ninja -t targets after cmake configure, because ninja aborts on an unknown target; a selection consisting only of such targets is a no-op, not a failure. A missing selection artifact must also fail the job rather than fall through to the default target, which would silently build everything. A NONE selection is read before the toolchain setup and the configure step, so a merge request that reaches no test costs a checkout rather than a full configure. rules: cannot decline to schedule that job in the first place, because GitLab evaluates them when the pipeline is created, before select:tests has run; only a child pipeline generated from the selection could.
The build script expands the surviving selection through ninja's phony edges before it shuffles and batches. Most selected names are aggregates — buildtests, and the parent of every split test — and the batch loop can only spread apart what it is handed, so an unexpanded parent would put a whole test family in one batch and undo the memory-pressure protection the batching exists for.
Two registrations do not reduce to a build target. buildtests aggregates the ei_add_test targets only, so a bare add_executable such as the bug1213 link regression is named explicitly alongside buildtests in the full-suite mode. The compile-failure suite under failtest/ is EXCLUDE_FROM_ALL and each of its CTest tests builds its own target as the test action, so those are selected as <name>_ok and <name>_ko CTest names and never handed to the build job. Both matter because a -R filter silently drops whatever it does not name, while the unfiltered runs in the other tiers pick them up for free.
Because that test action is a build in the shared binary directory, ei_add_failtest puts the whole suite behind one RESOURCE_LOCK. Without it, ctest --parallel starts dozens of concurrent builds over one build system and they collide whenever a regeneration is pending. The failure is not only noisy: _ko is WILL_FAIL, so a build system that errors for an unrelated reason satisfies it just as well as the compile error it is supposed to assert.
Every job in the default smoke matrix builds at baseline ISA, so a change under Eigen/src/Core/arch/AVX512 gets no AVX-512 compilation at all unless someone applies all-tests. Under the affected-tests label the tier adds platforms beyond the four unconditional jobs on two independent triggers, either of which is enough:
rules:changes: on the backend directory — automatic, and the common case;$CI_MERGE_REQUEST_LABELS — the axis orthogonal to the include graph. The graph decides which tests run; the labels decide where. Use this to run the affected tests somewhere the diff does not point at: a Core change on ppc64le, a Geometry change on Windows.| Backend directory | Label | Added configuration | In all-platforms |
|---|---|---|---|
arch/SSE | sse-tests | x86-64 gcc-10 baseline, AVX, and AVX-512DQ | yes |
arch/AVX | avx-tests | x86-64 gcc-10 AVX and AVX-512DQ | yes |
arch/AVX512 | avx512-tests | x86-64 gcc-10 AVX-512DQ | yes |
arch/AVX512/*FP16* | avx512-tests | the split gcc-13 AVX512-FP16 compile builds | no |
arch/NEON | neon-tests | 32-bit arm (aarch64 already runs unconditionally) | yes |
arch/AltiVec | altivec-tests | ppc64le gcc-14, under qemu | yes |
arch/LSX | lsx-tests | loongarch64 gcc-14, under qemu | yes |
arch/RVV10 | rvv-tests | riscv64 gcc-15, on the native runner | yes |
arch/SVE | sve-tests | SVE cross builds and test runs at 128, 256 and 512 bits under qemu | yes |
arch/SME | sme-tests | the full SME build, compile-only | no |
| — | windows-tests | MSVC 14.29 x64 baseline | yes |
arch/GPU, test/*.cu, test/gpu_common.h, unsupported/test/*.cu, unsupported/test/GPU/** | gpu-tests | the CUDA build and test jobs | no |
Each rule set matches the whole label string on its own, so several labels select the union of their platforms — neon-tests with altivec-tests runs 32-bit arm and ppc64le and nothing else. That is why these labels are unscoped: GitLab makes scoped labels (backend::NEON) mutually exclusive, so a scoped axis could never express a union, which is the point of the axis. Apart from gpu-tests, none of them does anything without affected-tests.
all-platforms is a shorthand for every row that runs the affected selection. The three rows marked “no” are excluded because their jobs ignore the selection and compile the whole suite instead — the AVX512-FP16 pair and the SME build are compile-only with no paired test job, and the GPU jobs build buildtests_gpu. Reaching those means naming their label, so all-platforms on a one-line change cannot silently buy hours of whole-suite compilation.
A wider x86 configuration compiles the narrower backends' headers, which is why SSE fans out to three builds. SME gets compile coverage rather than a selection because its per-SVL test jobs already filter to a curated target subset through EIGEN_CI_CTEST_REGEX, which a selection would fight with.
SVE runs the selection, at three vector lengths rather than one. The backend is fixed-length — EIGEN_ARM64_SVE_VL comes from __ARM_FEATURE_SVE_BITS, which only -msve-vector-bits sets — so each width is a separate build, and the packet code's fold counts and transpose networks differ between them. test/sve_vector_length guards the rest by reading RDVL and comparing it against the width the packets were built for, because a binary run at the wrong length does not fail to start; it computes the wrong answer while the suite passes. Both arch/SVE and arch/SME also list Eigen/src/Core/util/ConfigureVectorization.h in changes:, since that header decides whether either backend is compiled at all.
Windows has no changes: trigger. What MSVC catches that the Linux jobs do not — template instantiation limits, EIGEN_STRONG_INLINE behaviour, optimizer heap exhaustion — is whole-library rather than confined to a subtree a diff could name, so there is nothing to key an automatic rule on and windows-tests is the only way in. The selection is consumed by build.windows.script.ps1 and test.windows.script.ps1. Only MSVC x64 at baseline ISA is wired up; the 32-bit, AVX2 and AVX-512DQ Windows configurations stay in all-tests.
AVX512-FP16 headers are guarded by EIGEN_VECTORIZE_AVX512FP16, so an AVX512DQ build does not parse them, and the *FP16* row exists to compile them. Those jobs are compile-only because no current runner can execute AVX512-FP16 instructions.
The GPU row is the one entry that adds jobs outside the tier rather than an affected build and test pair, because no affected-tier configuration enables CUDA, HIP or SYCL. In a host-only build there is no gpu_basic, tensor_gpu, cusolver_* or cudss_* target at all, so a diff confined to the GPU test sources selects names that every affected build reports as unconfigured and hands the test jobs a -R regex matching nothing: every step exits 0 and the tier reads as green having compiled and run nothing. Those paths therefore add the existing CUDA jobs, through the affected-tests entry in .rules:libeigen:gpu. They ignore the selection — EIGEN_CI_BUILD_TARGET is buildtests_gpu and the test jobs filter on the gpu CTest label — so this is coverage of the whole GPU suite, not of the affected subset. gpu-tests is the platform label for this row and already triggers those jobs on its own, so it composes with affected-tests without a second rule entry.
arch/ZVector, arch/MSA, arch/HVX and the arch/HIP and arch/SYCL backends have no matching test configuration, so a change there gets only the four unconditional jobs and the same hollow result; gpu-tests is no help either, since the GPU jobs it gates are all CUDA. When adding a runner for one of these, add the trigger here too.
Inspect git status --short before formatting and preserve unrelated changes. Eigen requires clang-format-17 exactly; the pin lives in ci/checkformat.gitlab-ci.yml, which installs clang17-extra-tools. Format only files owned by the task:
clang-format-17 -i path/to/file.cpp path/to/header.h clang-format-17 --dry-run --Werror path/to/file.cpp path/to/header.h git clang-format --binary clang-format-17 --diff <base-sha>
.clang-format intentionally disables include sorting and registers Eigen-specific macros and attributes. Do not reorder includes or restyle those macros manually.
scripts/format.sh rewrites every matching file in the tree in parallel. Run it only when the worktree is clean or every affected change is owned by the task. Review git diff afterward in either case.
Run checks relevant to the changed files and report unavailable tools:
codespell --config setup.cfg path/to/changed-file reuse lint python3 scripts/check_style.py --diff <base-sha> python3 scripts/clang_tidy_hook.py --diff <base-sha> # needs clang-tidy
Both report only on the lines a change adds, and both are advisory. check_style.py covers the conventions clang-tidy cannot state — comment verbosity, and the declaration forms still awaiting a CustomChecks query (see the parked block in .clang-tidy). clang_tidy_hook.py runs clang-tidy itself, restricted to added lines with --line-filter; it needs no build directory, generating a driver that includes the module umbrella and then the edited Eigen/src header, the way ci/scripts/run-clang-tidy.sh does for merge requests. It skips silently when clang-tidy is absent, and shows the user a non-blocking notice when a file's translation unit does not compile.
Claude Code sessions run both automatically through the hooks registered in .claude/settings.json. Their unit tests, scripts/test_check_style.py and scripts/test_clang_tidy_hook.py, run in checkformat:scripts; run them after changing either script.
The whole-tree codespell invocation used by CI can expose pre-existing findings. Do not modify unrelated files merely to make a local broad scan clean. In the current CI configuration, clang-format, codespell, and clang-tidy jobs are allow_failure; treat their diagnostics as review findings anyway. The REUSE job is blocking.
Source-like files normally carry an inline SPDX copyright and license header using the file type's comment syntax. Files that should not carry inline comments need coverage in REUSE.toml. To process selected new source files with the repository helper, pass them explicitly because its default scan considers tracked files:
python3 scripts/add_spdx_headers.py --paths path/to/new-file.cpp
The documentation job is blocking and easy to miss. Unlike the clang-format, codespell, and clang-tidy jobs, build:linux:docs in ci/build.linux.gitlab-ci.yml is not allow_failure, and doc/Doxyfile.in sets WARN_AS_ERROR = FAIL_ON_WARNINGS_PRINT, so one Doxygen warning fails it. Its rules exclude the default merge-request pipeline: it runs on schedules, web pipelines, a merge request labeled all-tests, and a push to the default branch. A malformed \ref therefore passes an entire review green and breaks the pipeline on master after the merge. Apply the all-tests label to any merge request that touches Doxygen markup, a cross-reference target, or a documented name.
The recurring authoring mistake is trailing punctuation absorbed into a cross-reference: a colon directly after \ref name becomes part of the symbol Doxygen tries to resolve, so \ref adjoint: the ... fails while \ref adjoint. The ... resolves. Separate a reference from following prose with a space, comma, or period. Punctuation inside the name itself is fine — \ref MatrixBase::cross() is a qualified symbol, not a glued colon.
The doc target also compiles and runs the configured examples and snippets under doc/snippets, doc/examples, and their unsupported counterparts, by way of the all_snippets and all_examples prerequisites in doc/CMakeLists.txt. A renamed or removed public name breaks the documentation build even when every comment is well formed, so search those directories before changing one. “Configured” is the operative word: unsupported/doc/examples/CMakeLists.txt adds its SYCL subdirectory only under EIGEN_TEST_SYCL, which build:linux:docs does not set, so a broken unsupported SYCL example leaves this target green. Treat the target as coverage for the sets the configuration actually enables, and check the conditional before citing it as coverage.
EIGEN_BUILD_DOC defaults on for a top-level, non-cross-compiling configuration, but doc is excluded from all and must be named:
cmake --build build --target doc
Doxygen and graphviz must be installed. CI builds a pinned Doxygen from source (ci/scripts/build_and_install_doxygen.sh), so another local version can diagnose a different set of warnings; report the version that produced a local result.
Use the CI driver rather than invoking clang-tidy directly on an implementation header; the driver routes such a header through its public umbrella include.
cmake -G Ninja -S . -B .tidy-build \ -DCMAKE_CXX_COMPILER=clang++ \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DEIGEN_BUILD_TESTING=ON ci/scripts/run-clang-tidy.sh <base-sha> .tidy-build
The driver examines files committed between <base-sha> and HEAD; uncommitted-only edits are not included. Eigen's .clang-tidy policy is authoritative. Do not apply generic modernize-* or cppcoreguidelines-* campaigns.
A module that reaches a third-party header the machine does not install — <cuda_runtime.h> from unsupported/Eigen/src/GPU, <cholmod.h> from CholmodSupport — is still checked, but clang parses a truncated translation unit, so the driver marks the heading — partial: <header> is not installed and reports that file's findings without failing the job. Installing the dependency gets the module checked in full; for CUDA the driver looks under CUDAToolkit_ROOT, CUDA_HOME, CUDA_PATH, then /usr/local/cuda, and so does clang_tidy_hook.py. An unresolved in-tree include is a defect in the change and stays a hard error.
A header under arch/<ISA>/ other than arch/Default/ is not forced into the driver: it parses only under the -march/-mcpu that selects it, which this job does not pass. Such a header is linted only when the host target selects the backend — SSE2 on the x86-64 runner — and the heading says which backend went unchecked. Validate a change to one with a build that enables the ISA rather than relying on this job.
For a source in the compilation database the driver narrows that database first, through tidy_compile_db.py. A split test contributes one entry per EIGEN_TEST_PART, and clang-tidy parses the file once per entry naming it — 41 times for test/array_cwise.cpp — which alone exhausts the job's timeout. The reduction keeps one entry per distinct compiler configuration and, within a configuration split into parts, the parts that actually compile the added lines: a line inside a CALL_SUBTEST_<n>(...) or an #if defined(EIGEN_TEST_PART_<n>) guard needs part <n>, anything else needs no particular part. What that leaves out is printed beside the file name, so a capped run names the parts it did not check rather than reporting the file clean.
git diff and git diff --check.testing.md.doc target when the change touches Doxygen markup, a documented name, or a snippet, and label the merge request all-tests so the blocking documentation job runs before the merge rather than after it.