Add a host image for POSIX builds and the QA gates - #23
Merged
Conversation
A static analyzer reads the host's standard library, not the project's:
libc++ inlines the throw inside std::string and std::function, libstdc++
hides them behind external __throw_*, and the same pinned clang-tidy
therefore reports findings on macOS that CI never sees - and misses code
that is only red on a laptop. This image is the Linux answer, buildable
and runnable on either architecture.
It also replaces the apt-plus-build-paho preamble a consuming CI job runs
before every POSIX leg: build-essential, CMake, Ninja, ccache and
GTest/GMock, paho.mqtt.c built from a pinned commit with the same flags
that job used, and the QA toolchain (ruff, mypy, pytest, jsonschema,
clang-format, clang-tidy, lychee) in a venv first on PATH - Ubuntu 24.04
ships PEP 668, so there is no system interpreter to install into.
Everything it installs by name is pinned: pip with ==, paho to a commit
rather than a mutable tag, lychee to a per-architecture SHA-256 (the
amd64 tarball is not the arm64 one, so a single literal would leave one
leg unbuildable). Only UBUNTU_BASE_TAG reaches CI as a build arg, because
it is the one value the variant tag can name; the rest are ARG defaults,
the regime images/versions.json's _readme describes and check-pins.sh
enforces.
Two tools are wired up rather than merely installed, because installed is
not the same as in the path of a build. /usr/lib/ccache ships gcc and g++
wrappers but no cc or c++ - the names CMake looks for first - so a PATH
entry would leave a CMake project compiling through /usr/bin/c++ with the
cache untouched; CMAKE_{C,CXX}_COMPILER_LAUNCHER is read by CMake itself
and holds whatever compiler the project picks. CMAKE_EXPORT_COMPILE_COMMANDS
is what makes `clang-tidy -p build` resolve after an ordinary
`cmake -S . -B build`, rather than after a re-configure with one more flag.
The verification layer asserts rather than lists. The two clang tools have
to report the numbers pip was told to install - the wheel and the wrapper
are different things - jsonschema has to appear at its pinned version in
the freeze, CMake has to have taken the ccache launcher, and smoke/ is
configured, built and ctest-ed inside the build: it proves
find_package(GTest) resolves, that GMock links, and that the paho which
loads reports the version pinned beside its commit. clang-tidy is then run
over that same source, because a wheel whose binary cannot find its
resource directory answers --version perfectly and fails on the first real
file. That build runs with CCACHE_DISABLE=1: warming the cache as root
would leave /opt/ccache/tmp root-owned and every later `-u $(id -u)` run
failing on it. Nothing in the file pipes a tool's output anywhere, because
the image sets no SHELL and dash has no pipefail, so `gcc --version |
head -1` would take head's exit status and leave a missing compiler green.
The image runs as the invoking user: git safe.directory (a mounted
checkout is owned by a uid with no passwd entry, and without it both
`git ls-files` and `git describe` fail inside the container only), a HOME
of the image's own rather than /tmp, which a caller may replace mid-run,
and 1777 on both that and the ccache directory - an arbitrary uid has to
write them, and the sticky bit keeps one uid from replacing another's
files.
host.yml is its own workflow - nothing in this repo is built FROM it, so
it pushes on master alone, like platformio.yml.
The prose that counted the workflows is corrected with them: two sentences
in CLAUDE.md said "both workflows" where there are now three, and
dependabot.yml's list of what it deliberately does not track gains this
image's pins - ARG defaults, a commit ref and two literal checksums, none
of which any ecosystem resolves.
There was a problem hiding this comment.
Pull request overview
Adds a new host Docker image to this repository’s images/<name>/ roster, intended for POSIX (host) builds and QA gates (CMake/Ninja/GTest/GMock, plus ruff/mypy/pytest/clang-format/clang-tidy/lychee), along with the corresponding CI workflow and documentation updates.
Changes:
- Introduces
images/host/(Dockerfile + smoke-test CMake project + image README) to provide a reproducible Linux environment for host builds and static analysis on both amd64 and arm64. - Extends
images/versions.jsonwith ahostimage definition (platform runners, timeout, and the Ubuntu base tag arg) so matrices are generated consistently via existing scripts. - Adds
.github/workflows/host.ymlto build/push the new image family (build validation on PRs, tag publication onmaster) and updates root/guide docs to reference the new image.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds the Host workflow badge, adds host to the images table, and updates repo layout/docs references accordingly. |
| images/versions.json | Registers the new host image, including its platforms and build args, enabling matrix generation and version consistency checks. |
| images/host/smoke/smoke.cpp | Adds a small runtime/link smoke test covering GMock linkage and Paho version reporting. |
| images/host/smoke/CMakeLists.txt | Adds the CMake build definition for the smoke test used in the Dockerfile verification layer. |
| images/host/README.md | Documents tags, usage patterns, pin verification, and CI/container usage for the host image. |
| images/host/Dockerfile | Implements the host image build (apt + pinned venv tools + pinned Paho build + pinned lychee) and includes a verification layer that asserts installs/linking/tool behavior. |
| CLAUDE.md | Updates repository conventions to include the new host image and workflow in the documented roster/patterns. |
| .github/workflows/host.yml | Adds a new CI workflow to build multi-arch host images using matrices derived from images/versions.json. |
| .github/dependabot.yml | Documents that host image pins are intentionally manual (ARG defaults / commit pin / checksums), outside Dependabot’s scope. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A fourth image, for the target the other three do not cover: the machine the build
runs on. It compiles and tests a C++ project against the host's own toolchain and
carries the QA gates that run beside such a build —
ruff,mypy,pytest,clang-format,clang-tidyandlychee.Why an image rather than a list of
apt-get installlines in a CI jobA static analyzer reads the host's standard library, not the project's. libc++
(macOS) inlines the
throwinsidestd::stringandstd::function; libstdc++(Linux, and every CI runner) hides them behind external
__throw_*, where theanalyzer cannot follow. Same pinned
clang-tidy, different findings — so "tidy isgreen" means one thing on a laptop and another in CI, and the disagreement is
invisible until a colleague reports it. This image is the Linux answer, and it is
the same answer on either architecture: both
linux/amd64andlinux/arm64arebuilt natively, so a developer on Apple Silicon is not waiting on QEMU for the
analysis they asked for.
What is in it
build-essential(the sanitizer runtimes come with it),cmake,ninja-build,ccache,pkg-config,libgtest-dev,libgmock-dev,git,curl,jq,python3,python3-venvPATHruff,mypy,pytest,jsonschema,clang-format,clang-tidypaho.mqtt.c, fetched at a pinned commit and built with SSL off andHIGH_PERFORMANCEonlychee, verified against a per-architecture SHA-256Ubuntu 24.04 ships PEP 668, so a plain
pip installinto/usrrefuses; the venvis what makes
ruffandclang-tidycallable by bare name anyway.Pins
Everything the image installs by name is pinned, in the regime
images/versions.json's_readmealready describes andscripts/check-pins.shenforces: pip with
==, paho to a commit rather than a mutable tag,lycheeto aliteral checksum per architecture — the amd64 tarball is not the arm64 one, so a
single literal would leave one leg unbuildable.
Only
UBUNTU_BASE_TAGreaches CI as a build arg, because a variant's tag has toname every value it is built with and a tool version can never appear there. The
rest are
ARGdefaults, and_readme,CLAUDE.mdand.github/dependabot.ymlnow say so.
Two tools are wired up, not merely installed
Installing
ccachedoes not put it in the path of a build:/usr/lib/ccacheships
gccandg++wrappers but noccorc++, which are the names CMakelooks for first — so a
PATHentry leaves a CMake project compiling straightthrough
/usr/bin/c++with the cache untouched.CMAKE_{C,CXX}_COMPILER_LAUNCHERis read by CMake itself and holds whatever compiler the project picks.
CMAKE_EXPORT_COMPILE_COMMANDS=ONis what makesclang-tidy -p buildresolveafter an ordinary
cmake -S . -B build, rather than after a re-configure with onemore flag nobody remembers.
The verification layer asserts rather than lists
Beyond printing versions, the last layer requires that:
clang-formatandclang-tidyreport the numbers pip was told to install —the wheel and the wrapper it installs are two different things;
jsonschemaappears at its pinned version in the freeze it writes to/opt/qa-packages.txt— it is the one pin no tool here prints a version for;images/host/smoke/, a two-file CMake project, configures, builds and passesunder
ctest— provingfind_package(GTest)resolves, that GMock links, andthat the paho which loads reports the version pinned beside its commit;
clang-tidythen runs, over that project's own source and the compiledatabase the build just wrote. A wheel whose binary cannot find its resource
directory answers
--versionperfectly and fails on the first real file.That build runs with
CCACHE_DISABLE=1: warming the cache as root would leave/opt/ccache/tmproot-owned and every later-u $(id -u)run failing on it. Thesmoke sources stay at
/opt/smoke-src, so the same check can be re-run against apublished image — the README says how.
Nothing in the Dockerfile pipes a tool's output anywhere. The image sets no
SHELL, dash has nopipefail, andgcc --version | head -1would takehead'sexit status — leaving a missing compiler green in the layer written to catch
exactly that.
Running as the invoking user
The documented invocation mounts a checkout and passes
-u $(id -u):$(id -g), sothe repository is owned by a uid the image has no passwd entry for:
git config --system --add safe.directory '*'— without it git refuses thecheckout as dubious ownership and takes
git ls-filesandgit describewithit, inside the container only. It stays
'*'rather than naming/workspace,because a consumer running this image as a job
container:gets its checkoutunder
/__w/<repo>/<repo>, which a narrowed entry would refuse.HOME=/home/buildrather than/tmp, which a caller is entitled to replace(
--tmpfs /tmp, a volume over it, a cleanup step) and would takeHOMEwith itmid-run.
1777on both that and the ccache directory: an arbitrary uid has to writethem, and the sticky bit keeps one uid from replacing another's files.
CI
host.ymlis its own workflow — nothing in this repo is builtFROMthis image,so like
platformio.ymlit builds on every branch and pushes onmasteralone.Same three-job shape as the other families:
prepareresolves the matrices fromimages/versions.json,host-buildbuilds each platform on a runner of its ownarchitecture and pushes by digest,
host-manifestassembles the multi-arch tags —ubuntu-<version>,ubuntu-<version>-sha-<short-commit>, pluslatestandsha-<short-commit>for the primary variant.Two documentation sentences in
CLAUDE.mdsaid "both workflows" where there arenow three, and are corrected with them.
Verified locally
Built on
linux/arm64and exercised against the built image: every example inimages/host/README.md(build,ctest,git ls-files/git describeunder aforeign uid,
clang-format,clang-tidy -p build,lychee, sanitizer build,ccache hit,
ruff/mypy/pytest), plus./scripts/lint.shand./scripts/check-versions.sh. The verification layer was checked negatively too:building with a substituted
PAHO_VERSIONfails the image, as it should.