ADR 0048 / Deploy, release, and provisioning / 3 min read
Ship static musl release binaries with a CI guard, over glibc builds from whatever the runner has
Status: Accepted · shipped (the published Linux release assets of the open-source secrets launcher behind ADR 0028)
Source: vaulted-agent, a public repository. The original record is ADR 0001 under its docs/adr/, beside the code it governs.
Context
Release assets for a Rust CLI were built on the hosted CI provider's
latest-Ubuntu image. When that image moved to a release with glibc 2.39, the
published binary stopped loading on older distributions, failing with
version GLIBC_2.39 not found.
Nothing in the codebase calls anything from glibc 2.39. The dependency comes
from the Rust standard library's process-spawn path, which uses
pidfd_spawnp and pidfd_getpid when they exist at build time. Both are
weak symbols, but the version reference the linker records is not marked
VER_FLG_WEAK. The dynamic loader resolves version dependencies before it
looks up any symbol, so it rejects the image outright. There is no runtime
fallback and no way to satisfy it on the target: glibc is the distribution's
core C library, not something you upgrade under a supported install.
glibc is backward compatible and never forward compatible, so "build on new, run on old" is broken by construction, and which libc the build machine had is not a property anyone reviewing the repository would think to check. The affected hosts were most stable server distributions in service: the RHEL 9 family and Amazon Linux 2023 (2.34), Ubuntu 22.04 (2.35), Debian 12 (2.36). It was noticed when an install broke on a live host.
Decision
Published Linux binaries target *-unknown-linux-musl and link statically.
The *-unknown-linux-gnu targets are not published.
- The release workflow fails the build if a Linux asset has an
INTERPsegment or anyGLIBC_version reference. Without the guard this regresses invisibly: the binary runs fine on the runner that built it and on my machine. - The remote installer runs
<asset> versionbefore installing and falls through to the next candidate, or to a source build, if the asset will not start. A bad asset fails at install time, not at first launch.
Two alternatives were rejected. Building the gnu target in an older container, or with a cross toolchain pinned to an old glibc, works and keeps a glibc-native artifact - but it only moves the floor. The artifact still has one, and it silently tracks whatever base image CI happens to use. Publishing both doubles the matrix and puts the trap back into the installer's selection logic, for no benefit this binary can use.
Consequences
- One Linux asset per architecture runs on every distribution in service, with no floor to track.
- Static musl's real limitation is NSS.
getpwnamandgetgrnamcannot load directory-service modules, so users from LDAP or SSSD do not resolve in-process. That is safe here only because this binary never does user or group lookup in-process: it shells out toidand tosudo, which are ordinary dynamically linked distribution binaries and resolve directory users normally. - No extra toolchain step is needed; the compiler links these targets self-contained.
When I'd revisit
If in-process user or group lookup is ever added, this decision has to be revisited before that ships - it is the one change that silently breaks directory-backed users.