T27.AI

Blog

A Linux target with no ABI suffix is musl, and a CI gate had been trusting it

2026-09-06 · 4 min read

[measured] Zig resolves -target x86_64-linux to musl. A job added to catch platform-blind verification had been compiling every migrated file against a libc no real build of the project uses.

Three-panel engraved illustration for: A Linux target with no ABI suffix is musl, and a CI gate had been trusting it
View the complete triptych at full size
#Zig#CI#Toolchain#Reproducibility

A CI job in trinity-fpga compiles every file that has been migrated to Zig 0.16, one at a time, on Linux. It exists for a specific reason: an earlier version of the same check passed on a macOS laptop and failed on the runner, because macOS links libc implicitly and Linux does not. So the target was pinned rather than left native, and the comment beside it said the pin was "a no-op on this runner."

The pin was x86_64-linux. That is not the runner's libc. Zig resolves a Linux target with no ABI suffix to musl, and the runner — and every real build of this project — is glibc.

The three targets

Reproduce it with a file that does nothing. Zig 0.16.0, cross-compiling from macOS aarch64, one empty main and -lc:

printf 'pub fn main() void {}\n' > t.zig
for t in x86_64-linux x86_64-linux-gnu x86_64-linux-musl; do
  zig build-exe t.zig -target $t -lc -femit-bin=out_$t
  file out_$t
done
-targetfile(1) reports
x86_64-linuxstatically linked
x86_64-linux-gnudynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
x86_64-linux-muslstatically linked

The first row and the third row behave the same way and the middle one does not. Static linkage with no interpreter is the musl build; the glibc build carries a dynamic loader in its program headers. The unsuffixed target sits with musl, and the only target triple that appears as a string anywhere in the unsuffixed binary is x86_64-linux-musl — Zig records the resolved triple, not the one you typed.

Whether that difference can reach a real file is a question about the standard library. In the 0.16.0 install used here, std/c.zig contains eleven isGnu() or isMusl() call sites:

grep -cE '\.isGnu\(\)|\.isMusl\(\)' "$(dirname "$(readlink -f "$(which zig)")")/../lib/zig/std/c.zig"
11

What the gate could not have caught

A file that reaches one of those switches on the musl side compiles cleanly under x86_64-linux and fails under x86_64-linux-gnu. The job would have gone green and the real build would have gone red — which is the exact failure the job was created to prevent, moved one layer down. A check written against platform-blind verification was itself verifying against a platform nobody ships.

The fix is one suffix. The pin is now x86_64-linux-gnu, with the measurement written into the comment so the three characters do not read as decoration to whoever edits the line next. The false claim was corrected in place rather than deleted: "this used to be wrong and here is why" survives a later reader better than silence does.

One claim in the pull request does not reproduce

The pull request describes the musl-suffixed build as "byte-identical" to the unsuffixed one. That is not checkable this way, and the reason is worth more than the claim. Building the identical target three times, into three separate directories with the identical output name, gives three different binaries:

build of -target x86_64-linuxsize (bytes)sha256 prefix
first12,731,133468ce3a1
second12,731,1546998eff5
third12,731,1331d387f61

Three hashes, two sizes, from one command run three times. So the hash cannot discriminate between targets here at all — it does not even discriminate a target from itself, and neither does the size. What survives that control is the linkage reported by file(1) and the resolved triple recorded in the binary. Those two are the evidence; the hash never was.

This does not weaken the correction. It replaces a strong-sounding piece of support with a weaker piece that is actually true, which is the direction that costs nothing later.

Three commits, seventy minutes

The workflow file has exactly three commits, and two of them are corrections to the first:

PRmerged (UTC)what was wrong
#7462026-09-05 18:38:29the job is introduced
#7472026-09-05 18:57:37its predicate tested the type, not the entry point
#7492026-09-05 19:48:52its target resolved to musl, not glibc

Seventy minutes and twenty-three seconds from introduction to the second correction. Neither defect was visible in the YAML; both were found by extracting the step body and running it verbatim against the tree. Reading a gate tells you what it was meant to check. Running it tells you what it checks.

What this does not establish

No file in this tree was shown to depend on a musl-only symbol. The finding is that the gate could not have caught one, not that one is there. The target resolution was measured on one host cross-compiling, not on the runner; the runner is inferred to behave the same because it fetches the same Zig version. And the job compiles files — it does not run them. A file that links against the right libc has not thereby been shown to work.

What this does not settle

Receipts

Work with me

Want this kind of check on your own design?

I audit RTL and build independent, bit-exact models, then take the result through synthesis and, when useful, onto an Artix-7 board. The first conformance module is free.