Skip to content

Commit 13d46c5

Browse files
auxesisclaude
andcommitted
fix(ci): build the musl @cipherstash/auth binary in Alpine, and check 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
1 parent 4f11f41 commit 13d46c5

2 files changed

Lines changed: 107 additions & 9 deletions

File tree

‎.github/workflows/_build-auth-artifacts.yml‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,6 @@ jobs:
6565
ref: ${{ inputs.ref }}
6666
persist-credentials: false
6767

68-
- name: Install musl tools (linux-x64-musl)
69-
if: ${{ matrix.platform == 'linux-x64-musl' }}
70-
run: |
71-
set -euo pipefail
72-
sudo apt-get update
73-
sudo apt-get install -y musl-tools
74-
7568
# Rust 1.94.1 from the root mise.toml, the toolchain the crate is tested
7669
# with. `cache: false` is required here, not a default.
7770
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
@@ -104,17 +97,56 @@ jobs:
10497
run: npm install -g node-gyp
10598

10699
- name: Install dependencies
100+
if: ${{ matrix.platform != 'linux-x64-musl' }}
107101
run: pnpm install --frozen-lockfile
108102

109103
# `--js false` is load-bearing. With `--platform`, napi v2 also writes its
110104
# own `index.js` loader over the committed one, which is a published,
111105
# frozen file (release-gate.mjs FROZEN_ARTEFACT_DIGESTS).
112106
- name: Build the native binding
107+
if: ${{ matrix.platform != 'linux-x64-musl' }}
113108
working-directory: languages/typescript/packages/auth
114109
env:
115110
TARGET: ${{ matrix.target }}
116111
run: mise x -- pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false
117112

113+
# The musl binary is built inside Alpine Linux, a musl system, so the
114+
# compiler and its runtime libraries are musl's own. Built on the Ubuntu
115+
# runner, it linked the runner's glibc and failed to load on musl; the
116+
# suite's 0.44.0 has that fault. Ubuntu's musl-gcc cannot link a Rust
117+
# shared library (it has no musl libgcc_s), and the musl.cc toolchain
118+
# that _build-ffi-artifacts.yml downloads timed out from GitHub's runners
119+
# on 2 October 2026. The image is pinned by digest because its output is
120+
# published with provenance. `-crt-static` keeps the binary linked
121+
# against musl at load time, which a Node.js native module needs, and
122+
# which the C library check below reads. The same napi flags as the host
123+
# build, for the same reasons.
124+
- name: Build the native binding in Alpine (linux-x64-musl)
125+
if: ${{ matrix.platform == 'linux-x64-musl' }}
126+
env:
127+
TARGET: ${{ matrix.target }}
128+
ALPINE_NODE_IMAGE: node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
129+
run: |
130+
set -euo pipefail
131+
rust_version=$(mise current rust)
132+
pnpm_spec=$(node -p "require('./package.json').packageManager")
133+
docker run --rm \
134+
-v "$GITHUB_WORKSPACE:/build" -w /build \
135+
-e TARGET -e RUST_VERSION="$rust_version" -e PNPM_SPEC="$pnpm_spec" \
136+
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
137+
-e RUSTFLAGS="-C target-feature=-crt-static" \
138+
"$ALPINE_NODE_IMAGE" sh -euc '
139+
apk add --no-cache build-base cmake perl linux-headers git curl rustup
140+
rustup-init -y --profile minimal --default-toolchain "$RUST_VERSION" --target "$TARGET"
141+
. "$HOME/.cargo/env"
142+
corepack enable
143+
corepack prepare "$PNPM_SPEC" --activate
144+
pnpm install --frozen-lockfile --ignore-scripts --filter "@cipherstash/auth..."
145+
cd languages/typescript/packages/auth
146+
pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false
147+
chown -R "$HOST_UID:$HOST_GID" /build
148+
'
149+
118150
# The build must leave the tracked tree alone: `native.d.ts` is
119151
# regenerated and has to equal the committed copy, and nothing else may
120152
# change.
@@ -130,6 +162,32 @@ jobs:
130162
mv "stack-auth-node.${PLATFORM}.node" "platforms/${PLATFORM}/"
131163
test -s "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node"
132164
165+
# Every build checks which C library its binary links, so a release
166+
# cannot publish a musl binary that links glibc even if nobody ran
167+
# auth-preflight first. The same check as auth-preflight.yml's smoke test.
168+
# Into a variable, never into `grep -q`, so readelf cannot die on EPIPE.
169+
- name: Check which C library the binary links
170+
if: ${{ runner.os == 'Linux' }}
171+
working-directory: languages/typescript/packages/auth
172+
env:
173+
PLATFORM: ${{ matrix.platform }}
174+
run: |
175+
set -euo pipefail
176+
dynamic=$(readelf -d "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node")
177+
case "$PLATFORM" in
178+
linux-x64-gnu|linux-arm64-gnu)
179+
grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" || {
180+
echo "::error::$PLATFORM does not link glibc"; exit 1; } ;;
181+
linux-x64-musl)
182+
if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then
183+
echo "::error::linux-x64-musl links glibc — it is the gnu binary"
184+
exit 1
185+
fi ;;
186+
*)
187+
echo "::error::no C library rule for $PLATFORM"; exit 1 ;;
188+
esac
189+
echo "$PLATFORM: links the expected C library"
190+
133191
# `npm pack`, not `pnpm pack`: a platform package has no `workspace:`
134192
# dependency to rewrite. The wrapper does, and is packed with pnpm below.
135193
- name: Pack the platform package

‎scripts/__tests__/auth-build-artifacts.test.mjs‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ describe('_build-auth-artifacts.yml', () => {
5151

5252
it('builds each binding without writing over the committed loader', () => {
5353
const build = runs(binaries).filter((run) => /\bnapi build\b/.test(run))
54-
expect(build).toHaveLength(1)
54+
// One host build, and one inside Alpine for the musl leg.
55+
expect(build).toHaveLength(2)
5556
for (const flag of [
5657
'--platform',
5758
'--release',
@@ -60,7 +61,7 @@ describe('_build-auth-artifacts.yml', () => {
6061
'--dts native.d.ts',
6162
'--js false',
6263
]) {
63-
expect(build[0]).toContain(flag)
64+
for (const run of build) expect(run).toContain(flag)
6465
}
6566
})
6667

@@ -73,6 +74,45 @@ describe('_build-auth-artifacts.yml', () => {
7374
expect(check).toBeGreaterThan(build)
7475
})
7576

77+
it('builds the musl binding inside Alpine, from an image pinned by digest', () => {
78+
// Built on the Ubuntu runner, the musl binary linked glibc and failed to
79+
// load on musl. Alpine is a musl system, so its compiler links musl.
80+
const steps = binaries?.steps ?? []
81+
const musl = steps.filter((step) =>
82+
String(step?.if ?? '').includes("== 'linux-x64-musl'"),
83+
)
84+
const run = musl.map((step) => String(step?.run ?? '')).join('\n')
85+
const env = Object.assign({}, ...musl.map((step) => step?.env ?? {}))
86+
expect(env.ALPINE_NODE_IMAGE).toMatch(/-alpine@sha256:[0-9a-f]{64}$/)
87+
expect(run).toContain('docker run')
88+
expect(run).toContain('RUSTFLAGS="-C target-feature=-crt-static"')
89+
// The host build and the host dependency install skip the musl leg.
90+
for (const name of ['Build the native binding', 'Install dependencies']) {
91+
const step = steps.find((s) => s?.name === name)
92+
expect(String(step?.if ?? '')).toContain("!= 'linux-x64-musl'")
93+
}
94+
})
95+
96+
it('checks the C library of every Linux binary before it is packed', () => {
97+
// Without this, only a hand-run auth-preflight sees a glibc-linked musl
98+
// binary, and a release would publish it.
99+
const steps = binaries?.steps ?? []
100+
const check = steps.findIndex((step) =>
101+
String(step?.run ?? '').includes('readelf -d'),
102+
)
103+
const pack = steps.findIndex((step) =>
104+
/\bnpm pack\b/.test(String(step?.run ?? '')),
105+
)
106+
expect(check).toBeGreaterThan(-1)
107+
expect(check).toBeLessThan(pack)
108+
const run = String(steps[check].run)
109+
expect(String(steps[check].if)).toContain("runner.os == 'Linux'")
110+
expect(run).toMatch(
111+
/linux-x64-gnu\|linux-arm64-gnu\)[\s\S]*libc\\\.so\\\.6/,
112+
)
113+
expect(run).toContain('linux-x64-musl links glibc')
114+
})
115+
76116
it('packs the wrapper with pnpm, which rewrites its workspace peers', () => {
77117
const packs = runs(wrapper).filter((run) => /\bpack\b/.test(run))
78118
expect(packs.some((run) => /\bpnpm\b.*\bpack\b/.test(run))).toBe(true)

0 commit comments

Comments
 (0)