Container images
Every service image in the monorepo is built by the same small set of pieces. These are the following: one build-time toolchain image, one runtime base per language, and one shared Dockerfile per language family. This page explains why it is shaped that way, so the next change to it does not have to rediscover the reasoning.
The single most important rule is at the end: keep the families symmetric. The bulk of this page exists because they once diverged.
The rules this reasoning produced are in build rules. That page is what you must do. This one is why.
The pieces
| Image | Built from | Used by |
|---|---|---|
builder |
python:3.13.7-slim + Mise installing the root [tools] |
the build stage of both shared Dockerfiles, and py-build.bash |
go-runtime |
debian:trixie-slim |
the runtime stage of Go service images |
python-runtime |
python:3.13.7-slim |
the runtime stage of Python service images |
All three are published to the platform-staging Artifact Registry by
build-base-images.yml and consumed by digest.
builder is based on python:3.13.7-slim rather than a bare Debian image because it has to build both families. A
Python virtualenv records the interpreter it was built against, and python-runtime is what runs it. So the build stage
must ship that interpreter. Same image, not merely the same version. Both therefore pin the same digest, and
//infra/docker:check-image-pins fails if they drift apart. Nothing is lost on the Go side: python:3.13.7-slim is
itself Debian trixie, and the Go binaries are CGO_ENABLED=0 static.
Mise installs the toolchain to /opt/mise, not to its default under /root. /root is mode 0700, and
py-build.bash runs the builder as the invoking user so that the wheels it emits are owned by that user. Under /root
the toolchain would be unreachable to it.
The shape of a service build
Both shared Dockerfiles follow the same five steps:
FROMthebuilderimage, pinned by digest.cmpthe repo'smise.lockagainst the copy baked at/opt/builder-manifest, failing a build whose pinned builder predates the lock.- Stage the workspace. Manifests come first, so the dependency layer is reused when only source changes.
- Run one Mise task to produce the payload:
//<project>:installfor Go,//<service>:sync-venvfor Python. FROMthe matching runtime base, pinned by digest, and copy the payload in.
Step 4 is the rule, not a convenience: a Dockerfile must not spell out a build command of its own. If the image runs
go build or uv sync directly, then the image and a developer build two different things. Only one of them is covered
by CI. Put the command in a task under mise/scripts/ and have both call it.
Step 3 uses COPY --parents for the manifests, which needs
# syntax=docker/dockerfile:1 as the literal first line of the Dockerfile. Once any other comment or instruction
precedes it, a syntax= line is read as an ordinary comment and silently ignored.
How a version reaches a binary
BUILD_VERSION and BUILD_COMMIT arrive as build arguments, become -X linker flags in
GO_LD_FLAGS, and set BuildVersion and BuildCommit in
lyceum.technology/daedalus/version. docker-env.bash supplies the arguments, the root
mise.toml names the package in GO_VERSION_PACKAGE, and go-env.bash composes the flags.
Two things to know before you debug it:
- The linker silently ignores
-Xfor a symbol that is not linked. A binary that does not import the version package accepts the flags and drops them. That is how stamping was broken in six of eleven Go projects while looking configured: the flags were set, the package was reachable only throughdaedalus/handlerordaedalus/metrics, and only five services imported either. 0.0.0/unknownmeans the arguments never arrived, not that the binary is unversioned.go-env.bashfalls back tounknownwhengit rev-parseis unavailable, which is every container build. The version has to come in from outside.
stream_execlet is excluded. It is the only Go module with no daedalus dependency, and it is being
retired.
Pins
Every base reference is <image>:<tag>@sha256:<digest>. The digest is what pulls. The tag is for a human reading the
file. Three things keep that honest:
//infra/docker:repin-base-imagesrewrites the digests of our own published images, in every file that names them. It is the only thing that should.//infra/docker:check-image-pinsis static text: all copies of a digest agree, and the Python base tag matches.python-version. It runs in pre-commit.//infra/docker:check-pinned-tagspulls each pinned image and asks what it is, catching a tag that no longer describes its digest. It needs a registry, so it runs incheck-image-pins.yml.
check-pinned-tags deliberately does not compare a pin against the tag's current digest upstream. Upstream rebuilds a
tag for OS patches, which moves it away from a pin we chose on purpose. That comparison would then fail on every rebuild
rather than on a real problem.
Why one build path at all
The Makefiles it is replacing are already thin: 18 of them, most a single include. What makes the
change worth doing is what had grown around them.
- The wildcards lied.
appandllm-routerhad nolintorformattarget, so a repository-wide sweep was silently partial, and Python CI never called Make at all. - The seven Go images diverged on everything: source copying,
CGO_ENABLED, artefact paths, and every line of the runtime stage. One Dockerfile ends that, once the per-service ones are removed. - Five build workflows carry five copies of one setup job, two of them quietly broken.
- Python's checks covered far less than they looked. Critical-only lint over one directory. The
full ruleset surfaced hundreds of findings, including real
F821runtime bugs.
Same pattern each time: the tooling reported success over work it was not doing. Fixing that is worth more than the convenience of a single command.
Keep the families symmetric
Go and Python once built completely differently: Go on the Mise builder, Python on its own base with uv copied in from
a third image and uv sync spelled out in the Dockerfile. Every difference grew its own machinery to manage it:
- Python pinned
uvseparately, so a check existed to hold that pin equal tomise.toml. - Python pinned its own Python base, so a check existed to hold it equal to
python-runtime's. - Go staged a hand-written list of sibling modules, which did not scale past two.
- Because Go staged only a sliver of the repo, most
[monorepo].config_rootsentries were missing inside the image. Mise then warned once per missing root on every invocation, including shimmed tools such asgo. That produced six things:- A script to rewrite
config_rootsinside the image. - A second script to check the rewrite.
- A Mise task.
- A pre-commit hook.
- A build argument.
- A per-service declaration.
- A script to rewrite
None of that was solving a real problem. It was managing a difference that did not need to exist. Unifying the two builds deleted all of it and left the checks that guard something real.
So before adding a task, a check or a hook to the image pipeline, ask whether the check guards a genuine invariant or a consequence of the two families having drifted. If it is drift, then close the gap instead. The check you were about to write deletes itself.