|
cpp-toolchain documentation
Up-to-date C++ toolchain images for the complete development cycle - GNU and LLVM side by side
|
A gate that answers one question: do the published images still fill their purpose?
Two things can quietly take that away, and neither shows up as a failed build:
| Where | What it can do |
|---|---|
| Dockerfile - the snapshot realignment | apt-get dist-upgrade --allow-downgrades can hand a package back to the Ubuntu archive, replacing a PPA version with a much older one |
| Dockerfile - the runtime cleanup | apt-get purge --auto-remove can take more than it was asked to |
| scripts/install/binutils.sh | cross packages install best-effort: a package that does not install warns and the build still succeeds |
Without this gate, a -cross image that installed zero cross toolchains builds green and says nothing.
Renovate owns every version, through the annotated ARG block at the top of the Dockerfile.
A validation suite that repeated those versions would be a second source of truth to keep in step, so this one never states a version.
It asserts origin instead: the installed version of a package must come from the repository that is supposed to provide it.
That is what catches a downgrade, with nothing to maintain:
Letting the archive win here swaps the PPA's libstdc++ for the archive's much older one in runtime, silently, while build still compiles against the newer one.
The compilers to exercise are obtained by asking the installers that put them there:
gcc.sh and llvm.sh own how their packages are named, so they are the ones that can tell a compiler apart from the gcc-<major>-base and gcc-<major>-multilib packages that libgcc-s1 and libstdc++6 drag in. The gate holds no second opinion about it, and no helper of its own - the checks invoke those two scripts directly.
--list-installed is a pure query: it is dispatched before the root precondition, before gcc.sh adds the toolchain PPA, and before llvm.sh fetches the apt.llvm.org signing key. A check that asks what is installed must not modify what it is asking about.
The gate passes no --versions, which is what makes it report every installed major. Given one explicitly, --list-installed narrows to it - --versions=">=13" reports only the installed majors from 13 up.
So a GCC_VERSIONS=">=15" that resolved to two majors is exercised as two majors, and the selector is re-implemented nowhere.
Origin is asserted only for the majors the build argument names.
The distribution's own GCC arrives as a build-essential dependency, from the Ubuntu archive, and is a legitimate inhabitant of the image - demanding that every installed compiler come from the PPA would fail on it. It still gets compiled and run: it is in the image, so it should work.
When a build argument is a selector (>=15, latest-stable) rather than bare majors, which majors it produced cannot be recomputed, so the check falls back to requiring that at least one installed compiler comes from the expected repository.
Two throwaway stages. Nothing published inherits them, so no image gains a layer, and dev stays the last stage in the file so a bare docker build . is unchanged.
graph LR
base["BASE_IMAGE"] --> runtime
runtime --> build
build --> sa["static-analysis"]
build --> doc["documentation"]
sa --> dev
doc --> dev
build --> vb["validate-build"]
runtime --> vr["validate-runtime"]
vb -. "COPY --from<br/>/validate" .-> vr
classDef gate fill:#2d6a4f,stroke:#95d5b2,color:#ffffff
class vb,vr gate
The dotted edge compiles binaries in build and executes them in runtime, the only arrangement that proves what runtime ships can still run what build produces.
COPY --from is a native BuildKit cross-stage copy, so this costs one compile and one exec - no image is loaded onto the host, nothing is pulled from a registry.
flowchart TD
subgraph vb["validate-build (from build)"]
origins1["package-origins.sh build"]
compile["cxx-runtime.sh compile"]
libstdcxx["/validate/libstdcxx<br/>g++-N, clang++-N"]
libcxx["/validate/libcxx<br/>clang++-N -stdlib=libc++"]
cross["/validate/cross<br/><triplet>-g++"]
inspect["cxx-runtime.sh inspect<br/>readelf -d: the expected runtime is NEEDED"]
record["cxx-stdlib-parity.sh record<br/>/validate/stdlib.expected"]
compile --> libstdcxx
compile --> libcxx
compile --> cross
libstdcxx --> inspect
libcxx --> inspect
cross --> inspect
end
subgraph vr["validate-runtime (from runtime)"]
origins2["package-origins.sh runtime"]
verify["cxx-stdlib-parity.sh verify<br/>same SONAME, version, ABI"]
runr["cxx-runtime.sh run<br/>ldd resolves, binary exits 0,<br/>and reports the expected stdlib"]
end
src["test/cxx_runtime.cpp<br/>one payload, C++98-clean"] --> compile
libstdcxx ==>|"COPY --from"| runr
libcxx ==>|"COPY --from"| runr
record ==>|"COPY --from"| verify
The payload is compiled once per stable standard the compiler reports, for every installed compiler.
It is deliberately trivial and deliberately C++98-clean: its job is not to exercise language features - the compiler already guarantees those - but to produce a binary that dynamically links the C++ runtime and then resolves it.
inspect exists because a statically linked payload would resolve nothing at run time, which would make the whole runtime check pass while proving nothing.
| Directory | Holds | Inspected in | Executed in |
|---|---|---|---|
| libstdcxx/ | g++-N and clang++-N, native | build | runtime |
| libcxx/ | clang++-N -stdlib=libc++, native | build | runtime |
| cross/ | <triplet>-g++, foreign architecture | build | nowhere yet |
Each tree is named after the standard library its binaries link, not after the compiler that produced them: what has to be present at run time is the library, and one tree can hold both compilers. That is why clang++ appears twice - left alone it links libstdc++, GCC's being the Linux default, so libc++ is reached only through an explicit -stdlib=libc++.
The name is then the expectation, checked in the NEEDED entry inspect reads and in the stdlib= line the payload prints. Accepting either implementation in either tree is what would let a -stdlib=libc++ that quietly fell back to libstdc++ pass both.
Running the binaries proves the runtime works. It does not say why when it does not, because a version skew surfaces as an unresolved symbol and names nothing useful.
So validate-build records what it compiled against and validate-runtime checks it still has it:
The SONAME is the key, because it carries the whole contract: it is what the linker writes into a binary and the only name the loader ever looks up. Two libc++ releases coexist exactly when their SONAMEs differ, so counting installed libraries would report a conflict where the SONAME reports none - scripts/checks/README.md covers how apt.llvm.org arranges that. It also collapses multilib for free: the 32-bit and x32 libstdc++ share a SONAME with the 64-bit one, so build's three rows and runtime's one are the same single line.
The two implementations are then compared differently, because only one of them is ordered:
| Implementation | version | abi | Why |
|---|---|---|---|
| libstdc++ | equal | runtime >= build | GNU symbol versions are backward compatible: a library above the recorded GLIBCXX_ still defines everything below it |
| libc++ | equal | equal | no symbol versions at all, so there is no ordering to be lenient with |
Both sides read their answer from cxx-stdlibs.sh, whose library view needs no compiler, no binutils and no headers - which is what lets it answer inside runtime at all.
Two things it will not pretend to know:
Cross targets are the one thing taken from the build argument rather than from the image. BINUTILS_TARGETS is expanded by binutils.sh --list-targets, so an alias such as common resolves to the triplets it names. Asking the image what it installed would agree with whatever happened - including a target binutils.sh skipped silently, which is the failure this is here to catch.
All live under scripts/checks/, and the gate runs them from inside a built image.
Two of them are implementation details of this repository - they know its package origins and ask its installers what is present - so they sit in scripts/checks/details/:
| Script in details/ | Purpose |
|---|---|
package-origins.sh <build|runtime> | every toolchain package comes from the repository that owns it |
cxx-runtime.sh <compile|inspect|run> <directory> | build the payload, prove it links dynamically, prove it runs |
cxx-stdlib-parity.sh <record|verify> <file> | the stage that runs the binaries has the libraries the stage that built them used |
One level up sit the two that depend on nothing here. The gate uses both, but they answer on any machine, checkout or not:
| Script in checks/ | Purpose | Used by |
|---|---|---|
cxx-standards.sh [--stable] [--greatest] [--format=<default|std|cplusplus>] [compiler] | which C++ standards a compiler accepts | cxx-runtime.sh compile |
| cxx-stdlibs.sh [--view] [--stdlib] [--compilers] [--format] | which standard libraries are installed, and what ABI they expose | cxx-stdlib-parity.sh, package-origins.sh |
package-origins.sh uses it for the one thing it cannot write down: apt.llvm.org has spelled the libc++ runtime three ways - libc++1-17t64, libc++1-18, then plain libc++1 from LLVM 20, where the major left the name altogether. A check naming one of those keeps passing on the two it cannot see, so the package is discovered from the installed library instead and origin asserted on whatever answer comes back.
The details/ pair reaches scripts/install/ by relative path, which is why the validate stages copy scripts/ whole rather than scripts/checks/details/ alone. A layout that separates the two fails with cannot find scripts/install two levels above scripts/checks/details rather than silently finding no compilers.
This is not the top-level scripts/details/. Both names mean the same thing - implementation details of the directory that encloses them - but the top-level one is host-side tooling that .dockerignore keeps out of the build context entirely, whereas these checks have to ship into the image in order to validate it.
Each reports every failure before exiting, so one run tells you everything that is wrong.
cxx-standards.sh probes the compiler and reports what it accepts, ordered by __cplusplus:
Order by __cplusplus, never by the flag name: sorting c++98 c++11 c++26 as text or as versions puts c++98 last, because 98 is greater than 26.
--stable keeps only final spellings, dropping drafts such as c++2c. A draft and the final name it anticipates share a __cplusplus value, so the payload is compiled once per distinct value rather than once per spelling.
Which standards are reported and how they are reported are two separate choices. --stable and --greatest pick the rows; --format narrows each row to one field, which is what makes the output usable as a value rather than as a report:
| --format | Reports | Example |
|---|---|---|
| default | both fields | c++23 -> __cplusplus=202302 |
| std | the standard spelling alone | c++23 |
| cplusplus | the __cplusplus value alone | 202302 |
std is spelled the way the compiler spells it, so it can be fed straight back to it as -std=$(... --greatest --stable --format=std g++-16). cplusplus reports distinct values only: c++98 and c++03 both answer 199711, and a draft repeats the value of the final name it anticipates.
flowchart LR
pr["pull request<br/>push to main"] --> dbuild["docker-build.yml"]
dbuild --> g1["validate-build + validate-runtime<br/>normal and cross variants"]
trigger["release<br/>schedule<br/>dispatch"] --> dpub["docker-publish.yml"]
dpub --> g2["validate-build + validate-runtime<br/>--output type=cacheonly"]
g2 --> push["build + push"]
classDef gate fill:#2d6a4f,stroke:#95d5b2,color:#ffffff
class g1,g2 gate
On a pull request the validate stages are appended to the stage list the job already builds, so they cost a cache hit plus their own RUN. Before publishing they run as cacheonly solves - only the exit status matters, and the layers they build are the ones the pushes then reuse from the local cache.
A stage that lost a package never reaches a registry.
The scripts also run against a plain checkout, which is the quickest way to iterate on them. They discover whatever compilers the host has, so expect a wider matrix than an image produces:
record and verify back to back on one host trivially agree - what they are for is running either side of the COPY --from. To watch one fail, edit a version in the recorded file and verify again.
To narrow the matrix, copy scripts/ elsewhere and stub gcc.sh/llvm.sh --list-installed to return only the majors you care about.
Each fault below must turn the corresponding check red:
| Inject | Caught by |
|---|---|
| apt-get install -y --allow-downgrades libstdc++6=<archive version> in runtime | the origin check, then parity on GLIBCXX_, then run |
| apt-get purge -y libstdc++-15-dev in build | compile |
| add -static to the compile line | inspect, with no C++ runtime in NEEDED |
| remove a triplet from binutils.sh | inspect, on the cross binaries |
| drop libc++1 from llvm.sh's runtime_libraries_for | the origin check, then parity, then run /validate/libcxx |
| drop -stdlib=libc++ from the libcxx/ compile line | inspect and run, both naming the implementation they expected |
| edit a version in /validate/stdlib.expected | parity alone, before a single binary runs |