fix(ci): build the musl @cipherstash/auth binary in Alpine, and check every binary's C library - #1018
Conversation
… every binary's C library
The linux-x64-musl leg of _build-auth-artifacts.yml targeted
x86_64-unknown-linux-musl on the Ubuntu runner but named no musl linker,
so cargo linked the binary against the runner's glibc. Such a binary
fails to load on musl systems such as Alpine. auth-preflight's libc
check caught it: "linux-x64-musl links glibc — it is the gnu binary".
The suite's published @cipherstash/auth-linux-x64-musl 0.44.0 has the
same fault, because its workflow built musl the same way and never ran
such a check.
Build that leg inside node:22-alpine, pinned by digest: Alpine is a musl
system, so its compiler and runtime libraries are musl's own. The
container installs Rust at the version mise pins and pnpm at the root
packageManager version, installs only the auth package's dependencies,
and runs the same napi build, with -crt-static off so the binary links
musl at load time. Two other ways failed on 2 October 2026: Ubuntu's
musl-gcc cannot link a Rust shared library ("cannot find
libgcc_s.so.1"), and the musl.cc toolchain that _build-ffi-artifacts.yml
uses timed out from GitHub's runners.
Also check which C library each Linux binary links as it is built,
before the package is packed. Until now only auth-preflight.yml read it,
and nothing runs the preflight automatically, so a release could have
published a glibc-linked musl binary. The gnu binaries must need
libc.so.6, and the musl binary must not.
Tests in auth-build-artifacts.test.mjs cover the Alpine build, the
skipped host steps for the musl leg, and the C library check coming
before the pack.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
|
cipherstash-bot
left a comment
There was a problem hiding this comment.
Recommendation: 🟡 merge after changes
Moving the musl build into Alpine is the right fix. Running the C library check inside _build-auth-artifacts.yml closes a real gap: release.yml builds through this file, and nothing runs auth-preflight.yml automatically.
One thing to change before merge: the linux-x64-musl rule only proves the binary does not link glibc. It never proves the binary links musl, so a statically linked binary passes. Two test gaps are also worth closing in this pull request. Both gaps let a later edit remove protection while the tests still pass. Everything in the list below can wait for a follow-up pull request.
Other findings not posted as comments
- No test asserts that the
casestatement has a rule for every Linux platform in the build matrix. The*)branch does fail the job, so the mistake is caught. But it is caught at the end of a 60 minute Rust build, during a release or preflight run. It is not caught in the pull request that added the platform. The first test in the file already derives the platform list fromplatforms/, so the pattern exists..github/workflows/_build-auth-artifacts.yml:186 - No test asserts that either rejection branch calls
exit 1. An edit could keep thegreppatterns and the error text but dropexit 1. The step would then print an error and still pack the binary.scripts/__tests__/auth-build-artifacts.test.mjs:113 - The
linux-x64-muslleg still installs a host Rust target and node-gyp that it does not use.Add the Rust targetrunsrustup target add x86_64-unknown-linux-muslon the host.Install node-gypinstalls a global package. Neither reaches the Alpine container, which installs its own Rust. Addif: ${{ matrix.platform != 'linux-x64-musl' }}to both steps to save time on every release and preflight run. Keep thejdx/mise-actionstep, becausemise current rustneeds it..github/workflows/_build-auth-artifacts.yml:96 chown -R "$HOST_UID:$HOST_GID" /buildis the last command in the container. A failedpnpm installornapi buildtherefore leaves root-owned files in$GITHUB_WORKSPACE. GitHub hosted runners are deleted after the job, so nothing breaks today. It would break on a self-hosted runner. Atrapwould always run thechown..github/workflows/_build-auth-artifacts.yml:147- Nothing updates the Alpine image digest, and the
apk addline does not pin versions..github/dependabot.ymlhas nodockerentry. Itsgithub-actionsentry readsuses:lines only, so it cannot see an image name inside arun:script. The digest will stay at today's value and stop receiving Alpine security fixes.apk add --no-cache build-base cmake perl linux-headers git curl rustupresolves whatever versions Alpine serves on the day. Two builds of the same commit can therefore use different compilers. That matters here, because the build publishes the output with provenance..github/workflows/_build-auth-artifacts.yml:128
How this review was made
| Agent | Model | Review type | Result |
|---|---|---|---|
| claude | claude-opus-5 | infracode | 5 found, 2 posted |
| claude | claude-opus-5 | test-gap | 4 found, 3 posted |
| codex | gpt-5.6-terra | infracode | 1 found, 1 posted |
| codex | gpt-5.6-terra | test-gap | 1 found, 0 posted |
Synthesis: claude-opus-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 1 posted finding(s) were raised by two or more models.
Plain language: claude-opus-5 read every comment as a new reader would. 2 comment(s) had a problem that stopped the reader acting; it rewrote 0. It also rewrote the review body.
Stack: not part of a stack.
Context loaded: the description, 0 linked issue(s) and 1 discussion entries.
| grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" || { | ||
| echo "::error::$PLATFORM does not link glibc"; exit 1; } ;; | ||
| linux-x64-musl) | ||
| if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then |
There was a problem hiding this comment.
Change before merge: the linux-x64-musl rule rejects glibc, but it does not require the musl C library.
Impact: A binary with no C library NEEDED entry at all passes this check. The Rust target x86_64-unknown-linux-musl turns on crt-static by default, which links musl statically. The RUSTFLAGS="-C target-feature=-crt-static" line on line 137 is the only thing that turns that off. If someone removes that line, this step still prints links the expected C library, and the release publishes a binary that Node.js cannot load as a native addon. Nothing else catches it: auth-preflight.yml installs only the linux-x64-gnu platform package, so no job loads the musl binary.
Evidence: The gnu arm on line 178 requires NEEDED.*libc\.so\.6. The musl arm only rejects that entry. The passing run message quoted in the description, linux-x64-musl: no glibc NEEDED entry, is true for a static binary as well.
Fix: Require the musl C library after the glibc rejection. On Alpine x86_64 the shared library name is libc.musl-x86_64.so.1.
linux-x64-musl)
if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then
echo "::error::linux-x64-musl links glibc — it is the gnu binary"
exit 1
fi
grep -q 'NEEDED.*libc\.musl-' <<< "$dynamic" || {
echo "::error::linux-x64-musl has no musl libc NEEDED entry — it is linked statically"
exit 1; } ;;Add the same requirement to auth-preflight.yml:90, so the dry run and the build agree.
Found by 2 models: claude, codex
There was a problem hiding this comment.
Fixed in ea9dc4a0. The linux-x64-musl rule now requires a NEEDED entry for libc.musl-, here and in auth-preflight.yml. A statically linked binary now fails with linked statically.
| const build = runs(binaries).filter((run) => /\bnapi build\b/.test(run)) | ||
| expect(build).toHaveLength(1) | ||
| // One host build, and one inside Alpine for the musl leg. | ||
| expect(build).toHaveLength(2) |
There was a problem hiding this comment.
Change before merge: this test now expects two napi build steps, but the step-order test below still checks only the first one, so the new Alpine build is not covered.
Impact: Verify the build changed no tracked file runs git diff --exit-code on the auth package. It must run after every build step, because napi build regenerates native.d.ts and the committed copy must match. The test on line 68 uses all.findIndex((run) => /\bnapi build\b/.test(run)), which returns the index of the host build only. If an edit moves the Alpine build step after the git diff step, that test still passes. The musl leg would then pack a regenerated native.d.ts that nothing compared against the committed copy.
Fix: Check the order against every build step.
it('fails the leg when the build changed a tracked file', () => {
const all = runs(binaries)
const builds = all
.map((run, index) => index)
.filter((index) => /\bnapi build\b/.test(all[index]))
const check = all.findIndex((run) =>
new RegExp(`git diff --exit-code\\b.*${AUTH_DIR}`).test(run),
)
expect(builds).toHaveLength(2)
for (const build of builds) expect(check).toBeGreaterThan(build)
})Found by 1 model: claude
There was a problem hiding this comment.
Fixed in ea9dc4a0. The test now checks that Verify the build changed no tracked file comes after both napi build steps.
| ) | ||
| const run = musl.map((step) => String(step?.run ?? '')).join('\n') | ||
| const env = Object.assign({}, ...musl.map((step) => step?.env ?? {})) | ||
| expect(env.ALPINE_NODE_IMAGE).toMatch(/-alpine@sha256:[0-9a-f]{64}$/) |
There was a problem hiding this comment.
Change before merge: this test checks the value of ALPINE_NODE_IMAGE, but it never checks that docker run uses that variable.
Impact: An edit can write node:22-alpine as a literal in the docker run command and leave ALPINE_NODE_IMAGE unused. Lines 86 and 87 both still pass. The musl binary would then come from a mutable tag. The workflow comment on line 119 gives the reason the image must be pinned by digest: the output is published with npm provenance.
Fix: Assert that docker run names the variable, and that it carries no unpinned tag.
expect(env.ALPINE_NODE_IMAGE).toMatch(/-alpine@sha256:[0-9a-f]{64}$/)
expect(run).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/)
expect(run).not.toMatch(/docker run[\s\S]*\bnode:\d+-alpine(?!@)/)Found by 1 model: claude
There was a problem hiding this comment.
Fixed in ea9dc4a0. The test now checks that docker run uses "$ALPINE_NODE_IMAGE", and that no node:22-alpine tag appears without a digest.
Keeping the digest current, and pinning the apk packages, is in Linear issue CIP-4283.
|
|
||
| # Every build checks which C library its binary links, so a release | ||
| # cannot publish a musl binary that links glibc even if nobody ran | ||
| # auth-preflight first. The same check as auth-preflight.yml's smoke test. |
There was a problem hiding this comment.
Fix in a follow-up: this comment says the check is the same as the one in auth-preflight.yml, and nothing in the repository keeps the two copies the same.
Impact: The same C library rules now exist in three files: here, auth-preflight.yml:84-97 and ffi-preflight.yml:109-118. If someone changes a rule or adds a platform in one file, the others keep the old rule. The build step would then accept a binary the preflight rejects, or the reverse. The preflight is the dry run people read before a release, so the two disagreeing is worse than either rule alone. The new test in scripts/__tests__/auth-build-artifacts.test.mjs reads only this file.
Fix: Put the rules in one shell script, for example .github/scripts/check-c-library.sh <platform> <binary-path>, and call it from both auth workflows. If you keep the copies, add a test that reads the case block out of each file and asserts they match. The repository already does this for duplicated workflow logic in scripts/__tests__/eql-workflow-filters.test.mjs.
Found by 1 model: claude
There was a problem hiding this comment.
This is now Linear issue CIP-4284. It moves the rules into one script that all three workflows call, and tests that script directly.
freshtonic
left a comment
There was a problem hiding this comment.
Building the musl binary in Alpine is the correct fix, and the root cause in the description agrees with the old workflow. I read the diff and the earlier automated review. I agree with its first finding: the linux-x64-musl rule must also require a musl NEEDED entry, not only reject glibc.
Two more items that the earlier review does not include:
-
Load the binary in Alpine after the build (inline comment). At the moment no job loads the musl binary.
readelfonly infers that it will load. The container is already running, so arequirecosts almost nothing. It proves the claim of this PR directly. -
Add a changeset for the fix.
@cipherstash/auth-linux-x64-musl0.44.0 on npm does not load on musl. This PR is the fix for that published artefact. #1009 has six@cipherstash/authchangesets, so the version will increase and the fixed binary will ship. But none of the six tells Alpine users that the next version fixes their install. AGENTS.md (checklist item 9) asks for a changeset for a bug fix to a published package. A short'@cipherstash/auth': patchentry is sufficient, for example: "Thelinux-x64-muslbinary now links musl. In 0.44.0 it linked glibc and did not load on Alpine Linux." If the changeset must wait until #1009 merges, put it in #1009.
I did not run the workflow. I depend on run 37057641199 in the description for the result of the Alpine build.
cipherstash-bot's review of #1018, verified: - The linux-x64-musl rule only refused glibc, so a statically linked binary, with no libc entry at all, passed it. Node.js cannot load that as a native module, and no job loaded the musl binary: auth-preflight installs only the runner's own linux-x64-gnu package. Both the build and the preflight now require a NEEDED entry for musl's libc, and the preflight installs the wrapper and the musl package inside the pinned Alpine image and loads them. That step loads the fixed build, and fails on the suite's glibc-linked 0.44.0 with "Failed to load native binding". - The container's chown ran last, so a failed build left root-owned files in the workspace. A trap now runs it on every exit. - The musl leg no longer adds a host Rust target or installs node-gyp, which only the host build uses. Tests now cover: the tracked-file check coming after both builds; docker run using the pinned image variable, not a tag; the trap; musl being required in the build and the preflight; every rejection exiting 1; a rule for every Linux platform in the matrix; and the Alpine load step using the build's image. Each fails when its part is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
readelf only infers that the musl binary will load. Require it with Node.js inside the Alpine container straight after the build, so every build proves it, release builds included, and a missing runtime library or an unresolved symbol fails the job before the package is packed. A statically linked binary fails here too. freshtonic suggested this in review of #1018. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
…usl again @cipherstash/auth-linux-x64-musl 0.44.0 linked glibc, so the package did not load on musl systems such as Alpine Linux. #1018 fixes the build. None of the six carried changesets says so, and AGENTS.md asks for a changeset for a bug fix to a published package. It lives here because main's lint:auth-changeset refuses any @cipherstash/auth changeset until this PR removes the freeze. freshtonic raised it in review of #1018. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
No blocking issues were identified, and the linked preflight run completed successfully.
Review effort: Balanced
Findings: None
What changed in this PR
Fixes the @cipherstash/auth musl build before publishing is enabled, and adds checks to prevent incorrectly linked Linux binaries from shipping.
Changes:
- Builds the musl binary inside a digest-pinned Alpine container.
- Checks every Linux binary’s C library before packing.
- Adds an Alpine smoke test and workflow regression tests.
| File | Description |
|---|---|
| scripts/__tests__/auth-build-artifacts.test.mjs | Tests Alpine build configuration and validation steps. |
| .github/workflows/auth-preflight.yml | Requires musl linkage and smoke-tests loading in Alpine. |
| .github/workflows/_build-auth-artifacts.yml | Builds musl in Alpine and validates Linux linkage before packing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
freshtonic
left a comment
There was a problem hiding this comment.
I read the two new commits (ea9dc4a0, 6ba1a0dd) against my earlier review and the cipherstash-bot review. The merge-blocking items are fixed. I approve.
Fixed:
- The
linux-x64-muslrule now requires aNEEDEDentry forlibc.musl-, in the build and inauth-preflight.yml. A static binary now fails. - The build container loads the binary with
node -e "require(…)"afternapi build. The file namestack-auth-node.linux-x64-musl.nodeagrees withnapi.namein the authpackage.json. Atrap … EXITnow runs thechown, so a failed build does not leave root-owned files. auth-preflight.ymlinstalls the wrapper and the musl tarball in the same pinned Alpine image, and loads them.- The tests now check that the tracked-file check comes after both builds, that
docker runuses$ALPINE_NODE_IMAGE, that each::error::has anexit 1, and that each Linux platform in the matrix has a C library rule. These are the test gaps from the bot review. - The musl leg skips the host
rustup target addandnode-gypsteps.
Not blocking. Do these in #1009 or in a follow-up:
- Changeset. This PR still has no changeset. As I wrote before, a changeset in #1009 is sufficient. Please make sure that one of its
@cipherstash/authentries tells Alpine users that thelinux-x64-muslbinary now links musl. In 0.44.0 that binary did not load. - Three copies of the C library rules.
_build-auth-artifacts.yml,auth-preflight.ymlandffi-preflight.ymleach have a copy, and no test keeps them the same. This is the bot's open follow-up thread. - Alpine image digest and
apk addversions. Nothing updates the digest, andapk adddoes not pin versions.
Not verified by me: I did not run the workflow. Run 37057641199 in the description tested 618ced3b, which is before these two commits. Thus no run in the description shows the new Alpine smoke step in auth-preflight.yml or the require in the build container. The ea9dc4a0 commit message says that the smoke step loads the fixed build. Before #1009 merges, please run auth-preflight again on this head, or on #1009's branch after the merge, as the description plans.
|
Thank you both. Here is where each open item went.
After this PR merges, |
…packing Only ffi-preflight.yml checked which C library the Linux protect-ffi binaries link, and nothing runs that dry run automatically. release.yml builds through _build-ffi-artifacts.yml, so a release could publish a musl binary that links glibc. The suite published a glibc-linked @cipherstash/auth-linux-x64-musl 0.44.0 that way. Each Linux leg now runs scripts/check-c-library.sh on its binary after placing it and before packing it, as _build-auth-artifacts.yml does since #1018. scripts/__tests__/ffi-build-artifacts.test.mjs pins the step's place, its Linux condition and the binary it reads. Refs: CIP-4282 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
_build-ffi-artifacts.yml downloaded its musl toolchain from musl.cc. On 2 October 2026 that download timed out from GitHub's runners on six tries, so protect-ffi's next release would fail at that step. Its digest also pinned the file without authenticating where it came from. The linux-x64-musl leg now builds inside the node:22-alpine image, at the digest that _build-auth-artifacts.yml uses since #1018. Alpine is a musl system, so its own compiler links musl. The container runs the matrix's build script and log with -crt-static, places the binary as the host legs do, and loads it with Node.js on musl. The host build steps skip that leg, and the Rust version is the runner's, as on the other five legs. ffi-preflight.yml now also installs the wrapper and the musl tarball inside the same Alpine image and loads the binding, as auth-preflight does. A test checks that every workflow uses one pinned Alpine image. Refs: CIP-4282 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a
This PR fixes the musl build of
@cipherstash/auth. Today thelinux-x64-muslbinary links glibc, the C library most Linux systems use, instead of musl, the C library on systems such as Alpine Linux. On a musl system, the binary fails to load. This PR builds that binary inside Alpine, and makes every build check its own binary's C library before the package is packed.It must merge before PR E, #1009, which turns on publishing for
@cipherstash/authduring today's freeze for the stack crates import (Linear issue CIP-4274).The preflight found the fault
auth-preflight.ymlis a dry run of the@cipherstash/authrelease. It builds the seven packages, then checks each binary before installing it. For each Linux binary, it reads the binary's list of required libraries withreadelf -d. A gnu binary must requirelibc.so.6, which is glibc, and the musl binary must not.Run 37049240996, against PR E's branch, failed that check:
linux-x64-musl links glibc — it is the gnu binary.The fault is older than this repository. The suite's published
@cipherstash/auth-linux-x64-musl0.44.0 requireslibc.so.6and the glibc loaderld-linux-x86-64.so.2. The suite built musl the same way, and never ran this check.Why the binary linked glibc
_build-auth-artifacts.ymlbuilt the musl binary on the Ubuntu runner. It asked Rust for thex86_64-unknown-linux-musltarget and installedmusl-tools, but it never told Cargo which linker to use for that target. So Cargo used the runner's default linker, which links against glibc.Build the musl binary inside Alpine
Alpine Linux is a musl system, so its compiler and runtime libraries are musl's own. This is how napi-rs's own build templates build musl binaries. For the
linux-x64-muslleg only, the workflow now:node:22-alpine, pinned by image digest, because its output is published with provenance;mise.tomlpins, 1.94.1, and pnpm at the rootpackageManagerversion;napi buildcommand as the other legs;RUSTFLAGS=-C target-feature=-crt-static, so the binary links musl when it loads. A Node.js native module has to be built that way.Every later step is the same as before.
Two other ways failed on 2 October 2026:
musl-gcccannot link a Rust shared library, becausemusl-toolshas no musllibgcc_s:cannot find libgcc_s.so.1._build-ffi-artifacts.ymluses timed out from GitHub's runners on six tries in a row.Check every Linux binary as it is built
Until now, only the preflight read which C library each binary links, and nothing runs the preflight automatically.
release.ymlbuilds through the same file and publishes, so a release could have published a glibc-linked musl binary. Now every build checks its own binary before the package is packed:libc.so.6. The musl binary must not, and it must require musl'slibc.musl-x86_64.so.1. A statically linked binary has no C library entry at all, and Node.js cannot load it as a native module.The preflight also loads the musl package inside the same Alpine image now, through the wrapper. Before, its smoke test installed only the runner's own
linux-x64-gnupackage, so no job ever loaded the musl binary.The three commits
13d46c52builds the musl binary inside Alpine, and adds the C library check to every Linux build.ea9dc4a0acts on cipherstash-bot's review:trap;6ba1a0ddloads the musl binary inside the build container, as freshtonic suggested.How it was checked
auth-preflightat the head of this branch,6ba1a0dd, run 37062179778, passes. All seven builds pass, and so do the WASM and wrapper build and both smoke tests. The musl binary loads inside its build container. The preflight reportslinux-x64-musl: links musl, not glibc, andmusl smoke OKfrom inside Alpine.Failed to load native binding.scripts/__tests__/auth-build-artifacts.test.mjscover each part of the change. Each one fails when its part is removed.pnpm test:scriptspasses 1,103 tests, with 1 skipped, andpnpm run code:checkpasses.What happens next
auth-preflightruns again against PR E's branch, to confirm the fix there.@cipherstash/authpatch changeset for this fix, so the next release tells Alpine users. It cannot go here, becausemain'slint:auth-changesetrefuses any@cipherstash/authchangeset until PR E removes the freeze.ffi-preflight.yml. Linear issue CIP-4282 tracks both.🤖 Generated with Claude Code
https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a