Skip to content

Commit ea9dc4a

Browse files
auxesisclaude
andcommitted
ci: require musl in the musl binary, and load it inside Alpine
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
1 parent 13d46c5 commit ea9dc4a

3 files changed

Lines changed: 91 additions & 9 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
# `macos-latest` is arm64, so without it `darwin-x64` ships an arm64
8080
# binary.
8181
- name: Add the Rust target
82+
if: ${{ matrix.platform != 'linux-x64-musl' }}
8283
env:
8384
TARGET: ${{ matrix.target }}
8485
run: mise x -- rustup target add "$TARGET"
@@ -94,6 +95,7 @@ jobs:
9495
package-manager-cache: false
9596

9697
- name: Install node-gyp
98+
if: ${{ matrix.platform != 'linux-x64-musl' }}
9799
run: npm install -g node-gyp
98100

99101
- name: Install dependencies
@@ -136,6 +138,7 @@ jobs:
136138
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
137139
-e RUSTFLAGS="-C target-feature=-crt-static" \
138140
"$ALPINE_NODE_IMAGE" sh -euc '
141+
trap "chown -R \"\$HOST_UID:\$HOST_GID\" /build" EXIT
139142
apk add --no-cache build-base cmake perl linux-headers git curl rustup
140143
rustup-init -y --profile minimal --default-toolchain "$RUST_VERSION" --target "$TARGET"
141144
. "$HOME/.cargo/env"
@@ -144,7 +147,6 @@ jobs:
144147
pnpm install --frozen-lockfile --ignore-scripts --filter "@cipherstash/auth..."
145148
cd languages/typescript/packages/auth
146149
pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false
147-
chown -R "$HOST_UID:$HOST_GID" /build
148150
'
149151
150152
# The build must leave the tracked tree alone: `native.d.ts` is
@@ -182,7 +184,12 @@ jobs:
182184
if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then
183185
echo "::error::linux-x64-musl links glibc — it is the gnu binary"
184186
exit 1
185-
fi ;;
187+
fi
188+
# A static binary has no libc entry at all, and Node.js cannot
189+
# load it as a native module, so musl must be named.
190+
grep -q 'NEEDED.*libc\.musl-' <<< "$dynamic" || {
191+
echo "::error::linux-x64-musl has no musl libc NEEDED entry — it is linked statically"
192+
exit 1; } ;;
186193
*)
187194
echo "::error::no C library rule for $PLATFORM"; exit 1 ;;
188195
esac

‎.github/workflows/auth-preflight.yml‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ jobs:
9292
echo "::error::linux-x64-musl links glibc — it is the gnu binary"
9393
exit 1
9494
fi
95-
echo "linux-x64-musl: no glibc NEEDED entry" ;;
95+
grep -q 'NEEDED.*libc\.musl-' <<< "$dynamic" || {
96+
echo "::error::linux-x64-musl has no musl libc NEEDED entry — it is linked statically"
97+
exit 1; }
98+
echo "linux-x64-musl: links musl, not glibc" ;;
9699
esac
97100
checked=$((checked + 1))
98101
done
@@ -130,3 +133,28 @@ jobs:
130133
console.log('smoke OK')
131134
EOF
132135
node smoke.mjs
136+
137+
# The host step above installs only linux-x64-gnu, the runner's own
138+
# platform. The musl binary loads only where musl is the C library, so it
139+
# is installed and loaded inside Alpine, from the image the build uses.
140+
- name: Smoke-test the musl artifact inside Alpine
141+
env:
142+
ALPINE_NODE_IMAGE: node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
143+
run: |
144+
set -euo pipefail
145+
wrapper=$(basename "$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-[0-9]*.tgz)")
146+
musl=$(basename "$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-linux-x64-musl-*.tgz)")
147+
docker run --rm -v "$GITHUB_WORKSPACE/auth-dist:/dist:ro" \
148+
-e WRAPPER="$wrapper" -e MUSL="$musl" \
149+
"$ALPINE_NODE_IMAGE" sh -euc '
150+
mkdir -p /tmp/smoke && cd /tmp/smoke
151+
echo "{\"name\":\"smoke\",\"version\":\"1.0.0\",\"private\":true}" > package.json
152+
npm install --no-audit --no-fund "/dist/$WRAPPER" "/dist/$MUSL"
153+
node -e "
154+
const auth = require(\"@cipherstash/auth\")
155+
for (const name of [\"AccessKeyStrategy\", \"AutoStrategy\", \"OidcFederationStrategy\"]) {
156+
if (typeof auth[name] !== \"function\") throw new Error(\"no \" + name)
157+
}
158+
console.log(\"musl smoke OK\")
159+
"
160+
'

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,16 @@ describe('_build-auth-artifacts.yml', () => {
6565
}
6666
})
6767

68-
it('fails the leg when the build changed a tracked file', () => {
68+
it('fails the leg when either build changed a tracked file', () => {
6969
const all = runs(binaries)
70-
const build = all.findIndex((run) => /\bnapi build\b/.test(run))
70+
const builds = all
71+
.map((_, index) => index)
72+
.filter((index) => /\bnapi build\b/.test(all[index]))
7173
const check = all.findIndex((run) =>
7274
new RegExp(`git diff --exit-code\\b.*${AUTH_DIR}`).test(run),
7375
)
74-
expect(check).toBeGreaterThan(build)
76+
expect(builds).toHaveLength(2)
77+
for (const build of builds) expect(check).toBeGreaterThan(build)
7578
})
7679

7780
it('builds the musl binding inside Alpine, from an image pinned by digest', () => {
@@ -84,10 +87,19 @@ describe('_build-auth-artifacts.yml', () => {
8487
const run = musl.map((step) => String(step?.run ?? '')).join('\n')
8588
const env = Object.assign({}, ...musl.map((step) => step?.env ?? {}))
8689
expect(env.ALPINE_NODE_IMAGE).toMatch(/-alpine@sha256:[0-9a-f]{64}$/)
87-
expect(run).toContain('docker run')
90+
// The pinned image is the one that runs, not a mutable tag.
91+
expect(run).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/)
92+
expect(run).not.toMatch(/\bnode:\d+-alpine(?!@)/)
8893
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']) {
94+
// The container hands its files back even when the build fails.
95+
expect(run).toMatch(/^\s*trap "chown -R .*\/build" EXIT$/m)
96+
// The host steps that only the host build uses skip the musl leg.
97+
for (const name of [
98+
'Add the Rust target',
99+
'Install node-gyp',
100+
'Install dependencies',
101+
'Build the native binding',
102+
]) {
91103
const step = steps.find((s) => s?.name === name)
92104
expect(String(step?.if ?? '')).toContain("!= 'linux-x64-musl'")
93105
}
@@ -111,6 +123,41 @@ describe('_build-auth-artifacts.yml', () => {
111123
/linux-x64-gnu\|linux-arm64-gnu\)[\s\S]*libc\\\.so\\\.6/,
112124
)
113125
expect(run).toContain('linux-x64-musl links glibc')
126+
// A static binary has no libc entry, so musl must be named, not only
127+
// glibc refused.
128+
expect(run).toMatch(/linux-x64-musl\)[\s\S]*libc\\\.musl-/)
129+
// Every rejection fails the job, so the binary is never packed.
130+
const errors = run.match(/::error::/g) ?? []
131+
expect(errors.length).toBeGreaterThan(0)
132+
expect((run.match(/exit 1/g) ?? []).length).toBe(errors.length)
133+
// Every Linux platform in the matrix has a rule.
134+
const linux = (binaries?.strategy?.matrix?.include ?? [])
135+
.filter((leg) => String(leg.os).startsWith('ubuntu'))
136+
.map((leg) => leg.platform)
137+
expect(linux.length).toBeGreaterThan(0)
138+
for (const platform of linux) {
139+
expect(run).toMatch(new RegExp(`(^|[|\\s])${platform}[|)]`, 'm'))
140+
}
141+
})
142+
143+
it('auth-preflight requires musl, and loads the musl artifact inside Alpine', () => {
144+
const preflight = readWorkflow('.github/workflows/auth-preflight.yml')
145+
const steps = preflight?.jobs?.smoke?.steps ?? []
146+
const verify = steps.map((step) => String(step?.run ?? '')).join('\n')
147+
expect(verify).toMatch(/linux-x64-musl\)[\s\S]*libc\\\.musl-/)
148+
// The host smoke test installs only the runner's own platform, so without
149+
// this step no job loads the musl binary.
150+
const alpine = steps.find((step) =>
151+
/\bdocker run\b/.test(String(step?.run ?? '')),
152+
)
153+
expect(alpine).toBeDefined()
154+
expect(String(alpine.run)).toContain('linux-x64-musl')
155+
expect(String(alpine.run)).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/)
156+
// The same image as the build, so the load test matches the build.
157+
const build = (binaries?.steps ?? []).find(
158+
(step) => step?.env?.ALPINE_NODE_IMAGE,
159+
)
160+
expect(alpine.env?.ALPINE_NODE_IMAGE).toBe(build?.env?.ALPINE_NODE_IMAGE)
114161
})
115162

116163
it('packs the wrapper with pnpm, which rewrites its workspace peers', () => {

0 commit comments

Comments
 (0)