What the release image is built from. .tool-versions pins Elixir and
Erlang/OTP for the repository — mise reads it locally, erlef/setup-beam
reads it in CI. This file pins the image, and the two have to say the same
thing about Elixir and OTP: the release the container runs is compiled by the
toolchain named here.
| What | Version | Where it is written |
|---|---|---|
| Erlang/OTP | 28.5.0.4 | .tool-versions, Dockerfile (OTP_VERSION), .devcontainer/Dockerfile |
| Elixir | 1.20.2 (OTP 28) | .tool-versions, Dockerfile (ELIXIR_VERSION), .devcontainer/Dockerfile |
| Debian | trixie-20260713 | Dockerfile (DEBIAN_VERSION; ELIXIR_IMAGE is derived from it) |
| ffmpeg / ffprobe | 7.1.5 | Debian trixie's ffmpeg package; the major is asserted in CI, per architecture |
ELIXIR_IMAGE is derived from DEBIAN_VERSION in the Dockerfile rather than
written out, because the build and runtime stages must name the same Debian
version: the ffmpeg the argv contract is tested against has to be the package
the runtime stage installs.
Why Debian and not Alpine
The image was Alpine first, per the original stack decision, and had to move. On musl the BEAM intermittently aborts during startup:
sys_signal_stack.c:103:sys_sigaltstack(): Internal error: Failed to set alternate signal stackwith exit code 134 (SIGABRT). It is host-dependent, not load-dependent: the size
OTP requests for the JIT's alternate signal stack can fall below the kernel's
MINSIGSTKSZ, which grows with CPU features, and OTP's workaround for this only
ever worked against glibc.
This was measured at 2 failures in 10 runs on GitHub's runner fleet, in the
runtime container — the shipped image failing to boot, not a flaky build. On a
mixed fleet roughly one container start in five would have died, and the failure
never reproduces on a developer machine, which is what makes it worth this much
prose. It was caught by the container smoke suite before v0.1.0 was tagged.
Two consequences worth keeping in mind:
- Do not "optimise" the image back to Alpine for size. It costs about 80 MB over the Alpine equivalent. That is the price of a release that boots.
- The devcontainer was Debian all along, so dev and prod now agree on ffmpeg instead of differing by a major. The gap this file used to document is closed.
What CI asserts
The ffmpeg major is recorded once, on the line below, and both
bin/smoke-image and the image-ffmpeg CI job parse that
line. It is written as a key/value rather than as prose so there is exactly one
place to change and no second copy to fall out of step:
FFMPEG_MAJOR=7- The runtime image's
ffmpeg -versionmust report that major. A bump that moves it fails the pipeline until this file moves with it. - The
testandruntimestages must report the same ffmpeg build, so the encoder the argv contract is checked against is the one that ships. - Both published architectures must report the same full ffmpeg version.
The image is published as a manifest list holding linux/amd64 and linux/arm64,
and the
image-ffmpegjob runs once per architecture on native hardware; theffmpeg-arch-parityjob then compares the two Debian version strings, not just their majors.
If the architectures ever diverge
One version is recorded above because there has only ever been one: Debian
builds a source package once per suite and every architecture takes the same
version. That is the normal case, not a guarantee — an architecture can lag a
security upload by days, and when it does, ffmpeg-arch-parity goes red naming
both versions.
That failure is a decision, not a flake to retry. Either wait for the slower
architecture's build to land, or accept the split deliberately — in which case
the ffmpeg row above becomes two rows, one per architecture, and the release
notes say which architecture renders with which encoder. A mixed-version
release is a real difference in output bytes for the same URL, which is the
same class of change a pin bump is, and it belongs in the notes for the same
reason. FFMPEG_MAJOR stays a single line either way: a divergence that moved
the major is not a release to reconcile, it is a release to stop.
Bumping a pin
A pin bump changes what the image renders — a different encoder emits different bytes for the same URL — so it is a release, not a silent update. The procedure:
- Edit
Dockerfile(DEBIAN_VERSION,ELIXIR_VERSION,OTP_VERSION),.tool-versionsand.devcontainer/Dockerfiletogether; Elixir and OTP move as a pair. - Rebuild and read the new ffmpeg version out of the image:
docker build -t audio_proxy:pin-check . && docker run --rm --entrypoint ffmpeg audio_proxy:pin-check -version - Update the table above, and
FFMPEG_MAJORif the major moved. If it did, expect the argv contract to need real work — run the full:ffmpegsuite and read the failures rather than adjusting the assertion. - Regenerate the measured memory table:
bin/measure-ffmpeg-rss --write docs/capacity.md. A different ffmpeg holds different memory, and docs/capacity.md is what an operator sizes a container from — a stale table is a wrong memory limit. Commit the regenerated table with the bump; a bump that moves the numbers noticeably belongs in the release notes. - Regenerate the decision matrix on top of it:
bin/capacity-matrix --write docs/capacity.md. It reads the table step 4 just rewrote, so it has to run second, and it is what an operator actually reads — a correct table under a stale matrix is a wrong memory limit with an audit trail. Needs no docker; commit both in the same change. - Run
bin/smoke-imagelocally, then let CI run it again. - Cut a patch release (see docs/development.md).