Scenario S17: Multiple Build and Compiler Profiles¶
The same library gets checked on more than one
build profile — Linux/GCC, Linux/Clang, and
Windows/MSVC release, say — and a break on one platform/compiler is still a
break, even if the others look fine. ADR-047
§8's S17: which lanes are actual ABI contracts (gate CI, get a baseline) vs.
test-only CI lanes is an explicit .abicheck.yml allowlist, never "every CI
lane that happens to build this library."
profiles: has grown well beyond contract/os/arch since this scenario
was first written — it now also declares which compiler/dialect built each
lane, where that lane runs, and how it provisions its own toolchain. This
page covers the full picture; Project Targets
Schema stays the exhaustive
field-by-field reference — link there rather than re-deriving its tables.
A full multi-compiler, multi-OS example¶
profiles:
linux-gcc14:
contract: true
os: linux # -> runs-on: ubuntu-latest
dependency_source: conda-forge-gcc14
compile:
compiler_family: gcc
compiler_version: ">=14,<15"
binding: gcc14
standard: gnu++20
stdlib: libstdc++
frontend: castxml
linux-clang20:
contract: true
os: linux
dependency_source: conda-forge-clang20
compile:
compiler_family: clang
binding: clang20
standard: c++20
frontend: clang
windows-msvc:
contract: true
os: windows # -> runs-on: windows-latest
# No compile.binding here, unlike the two Linux profiles above — this
# is intentional, not an omission. MSVC's cl.exe isn't resolved through
# the binding/bindings-file mechanism at all (project validate skips
# identity probing for compiler_family: msvc, cl.exe having no
# --version flag to probe); this cell relies on the runner's
# system-default toolchain instead.
targets:
libfoo:
# A single glob, resolved by the calling workflow against each
# profile's OWN build-output directory — not per-OS. Linux's
# `libfoo.so`/`libfoo.so.1` and Windows' `foo.dll` don't share a
# naming convention a single glob can match, so a target checked on
# both platforms needs its build to stage a consistently-named/staged
# artifact per profile (e.g. always under `lib/libfoo.*`), not a
# pattern this schema resolves for you. This example's `lib/libfoo.*`
# only actually works if the Windows build is arranged to produce
# `lib/libfoo.dll` in the same relative location.
binary_pattern: "lib/libfoo.*"
checks:
- channel: accepted-main
depth: headers
# no explicit `profiles:` selector — the implicit sweep runs on
# every `contract: true` profile that actually built this target
baseline:
channels:
accepted-main: {source: actions-cache, key_prefix: "abicheck-baseline-main"}
Every checks[].channel must resolve to a declared baseline.channels id
(or the "none" no-baseline sentinel) — project validate rejects an
undeclared channel like a bare accepted-main above with no matching
baseline: block.
# bindings.yml — operator/CI-managed, never auto-discovered
schema: abicheck.toolchain-bindings/v1
bindings:
gcc14: /opt/gcc-14.2.0/bin/g++
clang20: /opt/llvm-20/bin/clang++
The sequence: validate → plan → per-cell checks → aggregate¶
abicheck project validate --toolchain-bindings bindings.yml
abicheck project plan --toolchain-bindings bindings.yml \
--build-output linux-gcc14=build-output/linux-gcc14 \
--build-output linux-clang20=build-output/linux-clang20 \
--build-output windows-msvc=build-output/windows-msvc \
-o run-plan.json
project validatechecks the declared topology — target/bundle/profile cross-references,checks[].channel/depth/gate_mode/profilesresolution, and (with--toolchain-bindings) that every declaredprofiles.<id>.compile.bindingactually resolves against the bindings file. It never reads a build output.project planrequires the config to already pass validation, then creates one check cell for each applicablechecks[]entry × profile assignment — only where the target actually appears in that profile's ownbuild-output.json, never a blind cross-product. A single(target, profile)pair can produce more than one cell when the target declares severalchecks[]entries (distinctchannel/depthpairs); an implicit sweep (nochecks[].profilesselector) silently skips a profile the target doesn't apply to, while an explicitchecks[].profilesselector naming a profile the target doesn't build on is a validation error, not a skip. Each cell resolves its own compiler binding/options and toolchain-identity probe along the way (skipped for a profile with no cells in the resolved plan, so an unused profile can't abort an otherwise-fine plan).check-project.ymlconsumes the run plan and fans out one matrix job per cell — each job runs on the profile's ownruns_on(nativewindows-latestfor the MSVC lane above), provisions dependencies via that profile's owndependency_source, and forwards the profile's owncompile_ast_frontend/compile_gcc_path/compile_gcc_optionsas that cell's--ast-frontend/--gcc-path/--gcc-options.- Each cell uploads its own report;
aggregatefolds them back into one gate — and, since report ids aretarget@profile#channel@depth-shaped, reconciles the same finding across profiles intofinding_matrix/profile_matrix(see Aggregate Reports for what "the same finding on two compilers" actually means).
Two different questions this schema answers¶
- One library, built under different producer compilers. Each
profiles.<id>.compile:block is that profile's own producer toolchain — the one that actually compiled the library binary (mangling, layout, vtables, calling convention, linked standard-library ABI). The example above is this case:linux-gcc14andlinux-clang20are two producer builds of the same library. - One producer artifact, consumed by clients built with a different
compiler.
consumer_compile:(a separate, optional sub-block, same shape ascompile:) declares a client toolchain — what a user of the library compiles their own code with against the public headers, when it differs from the producer (which#ifdefbranch a client sees, which standard-library ABI, which template instantiation). A profile with noconsumer_compile:behaves exactly as before — itscompile:doubles as the consumer's too:
These are genuinely different questions — don't reach for consumer_compile
to model "we also build this on Clang" (that's a second profiles: entry,
question 1); reach for it only when the same producer artifact needs to be
checked against a different client toolchain's view of the headers.
Current support / Not yet supported¶
Be precise about what actually runs today before wiring a CI matrix around it — several of these fields resolve into the run plan correctly but don't (yet) drive a second extraction pass:
| Capability | Status |
|---|---|
compile.frontend on a normal (non-bundle) target cell |
Applied end to end. check-project.yml forwards the resolved compile_ast_frontend as that cell's own --ast-frontend, taking precedence over the workflow-level input. |
compile.frontend on a kind: bundle check |
Projected, not applied. A bundle's operand is a staging directory, and the root Action rejects any non-auto frontend for a directory/package operand — such a cell keeps the workflow-global value. |
consumer_compile: fields reaching the run plan (consumer_compile_gcc_path/consumer_compile_gcc_options) |
Projected. Resolved into their own pair of run-plan fields, independent of the producer overlay. |
consumer_compile: actually driving a separate header-AST (L2) extraction pass merged with the producer binary's L0/L1 facts |
Not yet wired. This schema slice only projects the config axis; see docs/contribute/plans/g34-producer-consumer-compiler-profile-separation.md's Phase 0 for the remaining extraction/merge integration. |
consumer_compile.frontend forwarding |
Deliberately not forwarded, even though compile.frontend is — it describes the consumer half of a two-pass extraction that doesn't exist yet, so there's no second invocation to steer. |
os: windows scheduling a native windows-latest cell |
Works. An unset os: still resolves to ubuntu-latest (every profile written before this landed keeps its old scheduling); an unroutable value (e.g. os: freebsd) is a validation error, not a silent Linux fallback. |
dependency_source: unset on a Windows profile |
Falls back to system, not conda-forge — every conda-forge* source is Linux/macOS-only, and defaulting Windows to one would hard-fail before analysis even starts. An explicit conda-forge* on Windows is not silently rewritten; it still errors. |
compile.compiler_family/compiler_version/target |
Not forwarded as extraction flags — no invocation is composed from these fields directly. Separately, with --toolchain-bindings, both project validate and project plan do run a real identity check: they probe the resolved binding's actual executable and reject a mismatch against the declared compiler_family/compiler_version/target (validate checks every declared profile; plan checks only the profiles used by the resolved run). Without --toolchain-bindings, no probe runs and these fields are validated for shape only. |
| Native MSVC+PDB end-to-end verdicts | Experimental. A real fixture and CI lane exist (compiles with real cl.exe, runs a full dump+compare, marker msvc), but that CI job is non-blocking (continue-on-error) — abicheck's pure-Python PDB parser is still maturing against real MSVC output. Don't treat a green MSVC lane as an equally strict gate to the GCC/Clang lanes yet. |
When to move past this scenario¶
- You need an explicit profile selector (a check that should only run
on some, not every, contract profile) →
checks[].profiles:— see the Project Targets Schema — which is also a hard error, not a silent skip, if the named profile turns out not to build the target (a real misconfiguration, unlike the implicit sweep's legitimate "doesn't apply here"). - One target needs two baselines gated independently, on the same
profile → S21, two
check-targetcalls differing only inbaseline-channel. - You want to know whether a break is universal or profile-specific once
the matrix reports back → Aggregate Reports,
specifically the
finding_matrix'sscopefield.
See also¶
- Which Scenario Am I? — the full scenario index.
- Project Targets Schema — the full
profiles:/checks[].profiles:contract, includingcompile:/consumer_compile:/os:/dependency_source:field-by-field. - Run Plan Schema — the generated
RunPlanCheckshape each matrix cell resolves to. - Aggregate Reports — folding the matrix's reports back into one gate, and reconciling findings across profiles.
- Build Output Schema — why one artifact is always exactly one profile.