Continuous Integration¶
What CI is made of, and how to run it yourself.
The shape is one sentence: CI runs make targets inside a pinned
container, and every gate it runs is reachable from make gates. Both
halves are enforced rather than described — gates-check fails when CI
invokes a target gates cannot reach, and ci-image-check fails when the
tree stops describing the image CI runs in.
Run it the way CI runs it¶
The toolchain lives in an image, so "works on my machine" and "works in CI" can be the same sentence. Three targets:
make ci-shell # a shell in the pinned image
make ci-run TARGET='build test-rust' # any goals, in CI's environment
make ci-gates # the whole gate set, in CI's environment
ci-gates reproduces CI locally, for when a CI red needs more than its job
log. It composes gates rather than listing gates again: gates is already
every gate CI runs — gates-check enforces that against ci.yml — so the
only thing the container adds is the environment. It is not a pre-push
ritual: before pushing, run what proves your change and let CI be the gate
(Release, step 1's tip).
It is deliberately not a git pre-push hook. gates includes coverage at
roughly ten minutes, and a hook that slow is one people pass --no-verify to.
A gate that is routinely bypassed is decoration.
Container builds and host builds do not mix
ci-run builds into build-ci/, not build/, and sets both BUILD_DIR
and DOPPLER_BUILD_DIR. The container and the host target different
glibc versions, so handing one's build tree to the other fails at link
time with something like undefined reference: atan2f@GLIBC_2.43 — which
reads as a code bug and is not one. ffi/rust/build.rs locates the
library itself and defaults to the host tree, which is why the second
variable is needed as well as the first. Same separation, same reason, as
glibc-gate's build-glibc228/.
The toolchain image¶
The image is the org standard's (HAS_CI_IMAGE in the Makefile): canonical's
vendored docker/ci.Dockerfile builds it, .github/workflows/ci-image.yml
publishes it, scripts/ci-image.py decides when it owes a repin, and
.github/ci-images.env pins it. doppler supplies configuration — the
CI_IMAGE_* settings at the top of the Makefile — and one extension,
docker/ci-extra.sh. Why each pin exists is scripts/ci-image.py's
docstring; change any of the vendored files in canonical, never here.
Every Linux job used to open with make install-deps-ci, which apt-installs
the dev group — about 112 MB per job, ten jobs a run. Most of it was already
on the runner under a different owner: cmake and cargo live in /usr/local,
outside dpkg, so apt did not know they were there and fetched the distro
copies anyway. That download was the entire exposure to mirror weather, and
on one bad day it stalled five runs of a single PR, one job trickling for
21 minutes against a 25-minute ceiling.
The image has no package list of its own. It copies bootstrap.toml and
installs its CI_IMAGE_GROUPS (dev docs clang-rt) with a pinned just-bashit
release's install-deps.sh — the same file make install-deps reads. A
second list is exactly what bootstrap.toml exists to prevent, and it would
rot in the way hardest to notice: the image would keep working while no
longer being what a developer gets.
One thing is installed outside that list, by docker/ci-extra.sh, for a
reason one cross-distro package list cannot express. (clang's compiler
runtime used to be the second: it is now bootstrap.toml's clang-rt group,
whose apt cmd derives the release's package name and tolerates its absence,
and docker/ci-extra.sh keeps only the link probe that asserts it.)
| what | why it cannot come from bootstrap.toml |
|---|---|
nats-server |
make nats-up shells out to docker run, and there is no docker daemon inside a container job. Pinned by version and by the release's SHA-256. |
That last one matters more than it looks. The nats:// stream tests
self-skip when 127.0.0.1:4222 is unreachable, so without a broker the
suite would stay green while silently dropping the whole NATS path — and the
coverage number with it. scripts/start-nats.sh prefers the binary and falls
back to docker, so a dev box without docker gains those tests rather than
skipping them.
ci-extra.sh --fingerprint prints rustup, cargo and nats-server
version lines, which the image hashes together with every dpkg package. A
dpkg-only fingerprint once left an entire Rust toolchain invisible: anything
installed outside dpkg has to be in it, or the weekly comparison is blind to
exactly the parts installed by hand.
What is deliberately not in it¶
A rustup toolchain was, briefly — 613 MB, the single largest thing in the
image — carried only because ffi/rust/Cargo.lock had drifted to format v4,
which cargo refuses below 1.78 while apt ships 1.75 on both LTSes. That was a
workaround for a defect, not a requirement: the crate is edition 2021 with two
dependencies, and 1.75 compiles the whole tree in under four seconds.
The lockfile is back at v3, Cargo.toml declares rust-version = "1.75", and
make cargo-floor-check fails if either leaves the floor — because cargo
rewrites the lockfile to v4 the first time a modern one resolves anything, and
a lockfile is not a file anyone reads. Removing the workaround took the image
from 3.17 GB to 2.31 GB.
That is the shape to copy when this image grows: ask whether the thing being added is a requirement or a workaround, because a workaround baked into the environment is one nobody sees again.
Two bases, on purpose¶
build-and-test runs ubuntu-22.04 and ubuntu-24.04 because they are two
different glibcs. One image for both legs would leave that matrix naming two
environments while testing one, so CI_IMAGE_BASES names both and each is
built natively on amd64 and arm64.
The glibc 2.28 floor is a separate question
The floor is not what this image answers. make glibc-gate builds the
tree in Debian 10 and glibc-check reads libdoppler.so plus every
example binary, failing closed if it reads nothing; release wheels are
built in manylinux_2_28. A modern base cannot answer a floor question,
and the CI image does not try to.
Pinning, and the weekly re-pin¶
Jobs pin the image by digest, so an image rebuild cannot change what an in-flight PR was tested against.
The digest is written in exactly one place: .github/ci-images.env. No
workflow names one. A pin job reads that file and publishes the two refs as
job outputs, and every containerised job consumes
${{ needs.pin.outputs.image_2404 }}.
It was not always so, and the reason is worth keeping. The digest used to live
in the pin file and in six literal container: refs in ci.yml, while
nothing could move both: ci-image.yml writes the pin file, and the platform
refuses any GITHUB_TOKEN push touching .github/workflows/**. So the
repin branch was born failing lint
(#1215). A whole job for
two echos is the price of container: being resolved before any of its
job's steps run: nothing a step sets can reach it.
Every input to the image is pinned beside its digest — the apt snapshot every
source is rewritten to, each base by digest, and the just-bashit release — so
a rebuild reproduces the pinned package set
(#1748,
#1751). Only the Monday
ci-image.yml run (or a dispatch with refresh) picks new inputs, and it
repins only when a package fingerprint or the image's sources moved — a new
snapshot alone is not a change.
A weekly repin lands on a branch, not a PR (CI_IMAGE_LANDING = branch):
the doppler-dsp org forbids Actions from opening pull requests, so the run
pushes ci/repin-image and stops. On main, every ci-image.yml run then
ends red while that branch carries a pin main does not, until a human
lands it:
A push to any other branch that touches the image's sources builds from the pinned inputs and commits the new pin onto that branch, so the PR carries its own pin. To change what is in the image:
# edit bootstrap.toml or docker/ci-extra.sh, then:
make ci-image-build # [BASE=ubuntu:22.04] build one base locally
make ci-image-check # FAILS until the pin is updated -- that is the point
git push # ci-image.yml builds, smokes and commits the pin
ci-image-check runs inside make lint. It is offline and instant: the pin
must be complete and well formed, and its CI_IMAGE_SOURCE_HASH must be this
tree's hash of docker/ci.Dockerfile, docker/ci-extra.sh and
bootstrap.toml. It leaves out bootstrap.toml's [project] table, which
no layer reads, so a release's version bump owes no repin
(#1765).
ci-image-refs-check, also in make lint, asks doppler's half: does every
image a workflow can run in trace back to the pin file?
scripts/ci_image_refs_check.py resolves ${{ … }} rather than skipping
it — a needs.<job>.outputs.<name> ref is accepted only when that job is in
the consumer's needs, declares the output, and genuinely reads
.github/ci-images.env; a matrix.<key> ref is followed into the include
entries and each result re-checked. A literal is still legal and still must be
a pinned digest, because a tag is mutable by definition. Finding zero
references is a failure too: a scan that matches nothing has not run.
The compiler cache¶
The C core is built several times per run with the same compiler and the same
flags — once per Python job (only the extension differs per ABI) plus the
ubuntu-24.04 leg. ccache is in every dev group and reaches every configure
step, including the coverage tree's.
Measured in the image: a cold build is 434 misses, and a rebuild after
deleting the tree is 434 hits — the whole second build served from cache. In
CI the Build step went from 79 s to 12 s once a cache existed to restore.
What is not duplicated stays that way, by construction rather than by our being careful: ccache hashes the compiler binary and the full flag set, so the 22.04 leg's gcc, coverage's instrumented clang and the Debian 10 floor toolchain land in separate entries.
make ccache-stats runs after each build so the hit rate is in the log. A
cache that quietly stops hitting has no other symptom than builds slowly
getting longer.
Gates that watch CI itself¶
These exist because each one failed to hold at least once:
| gate | what it refuses |
|---|---|
gates-check |
CI running a make target that make gates cannot reach. It reads one file, so moving steps out of ci.yml into a composite action shrinks what it checks while still reporting OK — it dropped from 29 targets to 21 that way once, which is why the build/test steps are inline. |
ci-image-check |
The tree describing an image CI is not running (canonical's); ci-image-refs-check adds a container: that does not trace to the pin, such as a mutable tag. |
deps-budget-check |
DEPS_DEADLINE × DEPS_TRIES + backoff exceeding the smallest step ceiling in any workflow, so a retry cannot be killed mid-download. |
lint-ci-pipefail |
A workflow step whose shell pipeline discards an exit code. The default Actions shell is bash -e, where a pipeline reports the last command's status — make coverage \| tee was green over a recipe that had failed, and the missing report only surfaced a step later. |
Pull requests¶
ci.yml runs on pull_request and on push to main / develop, and it
does the same work on both: every job, and python on every version
3.9–3.14. The PR run is the one that gates the merge (CI passed, the
one required check), so it is the full matrix.
There is no merge queue. It was retired on 2026-10-01. While it ran, a
PR got only the fast gates and the queue ran the heavy jobs on main plus
the PR. That split meant a PR's own checks could be green while the run
that decided the merge had not started. It also meant a pending CI-image
repin ejected every queued PR. Instead, protect-main requires a PR to be
up to date with main before it merges, so the tree a PR's run tested is
the tree that lands. A behind PR rebases and runs again. See
How We Work.
The primary leg. Some steps of the python job are worth running once,
not on every interpreter. They run on the primary leg, which is named by
role, never by version: make print-python-primary prints it (through
scripts/python_versions.py --primary), and the rule is that the primary
is the floor. These steps used to select
'3.12', the first leg of the original matrix. When 3.9 was added below it
the literal stayed, and while a PR ran the floor alone, no PR ran them
(#1714).
The pythons job emits a primary output, and a step's if: compares
matrix.python-version against it one of two ways. Which one is the step's
lane:
== primary: primary, the primary leg alone. For the version-independent gates and the coverage-producing run.!= primary: rest, every other leg.
| step | target | lane | cost (run 36820085885, 3.12) |
|---|---|---|---|
| Doc fence gates (python + C + shell) | make test-snippets |
primary | 74 s |
| Test | make test-python |
rest | 264 s (3.9 leg) |
| Test with coverage | make test-python |
primary | 288 s |
| Validation reports are not stale | make validate-check |
primary | 233 s |
| Upload coverage report | actions/upload-artifact |
primary | 1 s |
make ci-aggregator-check holds this table to ci.yml: same steps, same
targets, same lanes. It refuses any other leg selector, a version literal
above all, and a pythons job that does not derive primary from the
classifiers. To move a step between lanes, flip its comparison and this
table's row.
The pythons job also emits the Python matrix itself (its pythons
output: every version, or the primary alone when code=false). It is
doppler's own, so it sits beside changes rather than in it. The matrix is
read from pyproject.toml's
Programming Language :: Python :: 3.N classifiers by
scripts/python_versions.py, so ci.yml names no version.
make python-versions-check holds the lowest classifier equal to the
requires-python floor and every classifier inside its range.
changes is canonical's. It is the vendored
.github/workflows/changes.yml (standard-check holds it byte-identical),
the same job every just-buildit repo calls, so the rules below are the org's.
It writes src, docs and code on every path; src=false implies
code=false. make ci-changes-wiring-check refuses a ci.yml job that
ignores it, except the three this repo runs on every tree by policy, declared
in the Makefile's CI_ALWAYS_RUN_JOBS: pin (a repin PR must resolve its
image), pre-commit and manifest-drift (lint and drift check exactly what
a version bump changes).
src=false: nothing here is untested. changes answers it for two
reasons. A diff that is a version bump alone (make ci-changes). Or, on a
push to main, a tree that already passed as a PR whose head contained the
previous tip (make ci-tree-tested). protect-main requires a PR to be up
to date, so that is every merge: main's push run checks the cheap gates
and skips the matrix the PR just ran.
code=false: only docs changed. make ci-docs reads CI_DOCS_RE in
the Makefile, the one declaration of what the docs are (docs/,
mkdocs.yml, the changelog, README.md, CONTRIBUTING.md). When every
changed path matches, and nothing outside docs/ or changelog.d/ was
deleted, it answers code=false. Then every job docs cannot break skips:
the C builds on every platform, Doxygen (it reads native/ only),
sanitizers, coverage, glibc, packages, Docker and the sweep. Each is in
CI passed's CODE_ONLY list. What docs can break still runs: lint,
the site build, and the python job, on the primary leg alone. Docs can
fail the suite (the doc fences, and the gate tests that read the live tree),
but not on one interpreter more than another. make ci-aggregator-check
holds CODE_ONLY to exactly the jobs gated on code, as it holds
SKIPPABLE to the jobs gated on src.
Nightly, main gets the full run anyway. A schedule run at 03:43 UTC
never takes a skip. Its job is the environment, not the tree: hosted runner
images and unpinned tools move under an unchanged tree, and the nightly is
where that goes red, rather than on whichever PR meets it first.
Every heavy job is gated on if: needs.changes.outputs.src == 'true', and
scripts/ci_passed.py treats a skip as green only for a job in the
aggregator's SKIPPABLE list and only on src=false.
make ci-aggregator-check holds SKIPPABLE to exactly the gated jobs. It
also refuses any surviving trace of the queue's split: a full, heavy or
primary_full output, a job gated on one, or a HEAVY list. Each of those
would be a skip permission that nothing takes.
Per-PR bases. CHANGELOG_BASE (for changelog-check and
issue-link-check) and coverage's COV_BASE both read
pull_request.base.sha, falling back to origin/main on a push.
Merge method: rebase. issue-link-check reads the Closes #N /
No-issue: declaration from commit messages. A squash commit carries
whatever message the squash template produces, which can drop that
declaration.
windows.yml is advisory.
What runs where¶
| job | environment | notes |
|---|---|---|
build-and-test-linux |
pinned image, one per glibc | split from macOS because container: is Linux-only and cannot be switched off for one matrix leg |
build-and-test-macos |
hosted runner, brew | no macOS container to bake |
python (3.9–3.14) |
pinned image | uv supplies the interpreters; only the extension build differs per ABI |
coverage |
pinned image | clang source-based, C ∪ Python ∪ Rust — see Coverage |
glibc-228 |
Debian 10 image via make glibc-gate |
the floor gate; its own toolchain by necessity |
doxygen, docs, pre-commit, manifest-drift, specan-demo |
pinned image or plain runner | no system deps beyond the image |
docker |
hosted runner | builds the shipped images, so it needs a daemon |
One check name is load-bearing: CI passed. It is the only status check
the protect-main ruleset requires, and it is green only when every job in
its needs succeeded. So the other jobs can be renamed freely, and a job
left out of that list gates nothing, however red it goes.
make ci-aggregator-check fails when a job in ci.yml is missing from
ci-passed's needs.