-
Notifications
You must be signed in to change notification settings - Fork 8
fix(ci): build the musl @cipherstash/auth binary in Alpine, and check every binary's C library #1018
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(ci): build the musl @cipherstash/auth binary in Alpine, and check every binary's C library #1018
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,13 +65,6 @@ jobs: | |
| ref: ${{ inputs.ref }} | ||
| persist-credentials: false | ||
|
|
||
| - name: Install musl tools (linux-x64-musl) | ||
| if: ${{ matrix.platform == 'linux-x64-musl' }} | ||
| run: | | ||
| set -euo pipefail | ||
| sudo apt-get update | ||
| sudo apt-get install -y musl-tools | ||
|
|
||
| # Rust 1.94.1 from the root mise.toml, the toolchain the crate is tested | ||
| # with. `cache: false` is required here, not a default. | ||
| - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 | ||
|
|
@@ -86,6 +79,7 @@ jobs: | |
| # `macos-latest` is arm64, so without it `darwin-x64` ships an arm64 | ||
| # binary. | ||
| - name: Add the Rust target | ||
| if: ${{ matrix.platform != 'linux-x64-musl' }} | ||
| env: | ||
| TARGET: ${{ matrix.target }} | ||
| run: mise x -- rustup target add "$TARGET" | ||
|
|
@@ -101,20 +95,64 @@ jobs: | |
| package-manager-cache: false | ||
|
|
||
| - name: Install node-gyp | ||
| if: ${{ matrix.platform != 'linux-x64-musl' }} | ||
| run: npm install -g node-gyp | ||
|
|
||
| - name: Install dependencies | ||
| if: ${{ matrix.platform != 'linux-x64-musl' }} | ||
| run: pnpm install --frozen-lockfile | ||
|
|
||
| # `--js false` is load-bearing. With `--platform`, napi v2 also writes its | ||
| # own `index.js` loader over the committed one, which is a published, | ||
| # frozen file (release-gate.mjs FROZEN_ARTEFACT_DIGESTS). | ||
| - name: Build the native binding | ||
| if: ${{ matrix.platform != 'linux-x64-musl' }} | ||
| working-directory: languages/typescript/packages/auth | ||
| env: | ||
| TARGET: ${{ matrix.target }} | ||
| run: mise x -- pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false | ||
|
|
||
| # The musl binary is built inside Alpine Linux, a musl system, so the | ||
| # compiler and its runtime libraries are musl's own. Built on the Ubuntu | ||
| # runner, it linked the runner's glibc and failed to load on musl; the | ||
| # suite's 0.44.0 has that fault. Ubuntu's musl-gcc cannot link a Rust | ||
| # shared library (it has no musl libgcc_s), and the musl.cc toolchain | ||
| # that _build-ffi-artifacts.yml downloads timed out from GitHub's runners | ||
| # on 2 October 2026. The image is pinned by digest because its output is | ||
| # published with provenance. `-crt-static` keeps the binary linked | ||
| # against musl at load time, which a Node.js native module needs, and | ||
| # which the C library check below reads. The same napi flags as the host | ||
| # build, for the same reasons. | ||
| - name: Build the native binding in Alpine (linux-x64-musl) | ||
| if: ${{ matrix.platform == 'linux-x64-musl' }} | ||
| env: | ||
| TARGET: ${{ matrix.target }} | ||
| ALPINE_NODE_IMAGE: node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 | ||
| run: | | ||
| set -euo pipefail | ||
| rust_version=$(mise current rust) | ||
| pnpm_spec=$(node -p "require('./package.json').packageManager") | ||
| docker run --rm \ | ||
| -v "$GITHUB_WORKSPACE:/build" -w /build \ | ||
| -e TARGET -e RUST_VERSION="$rust_version" -e PNPM_SPEC="$pnpm_spec" \ | ||
| -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ | ||
| -e RUSTFLAGS="-C target-feature=-crt-static" \ | ||
| "$ALPINE_NODE_IMAGE" sh -euc ' | ||
| trap "chown -R \"\$HOST_UID:\$HOST_GID\" /build" EXIT | ||
| apk add --no-cache build-base cmake perl linux-headers git curl rustup | ||
| rustup-init -y --profile minimal --default-toolchain "$RUST_VERSION" --target "$TARGET" | ||
| . "$HOME/.cargo/env" | ||
| corepack enable | ||
| corepack prepare "$PNPM_SPEC" --activate | ||
| pnpm install --frozen-lockfile --ignore-scripts --filter "@cipherstash/auth..." | ||
| cd languages/typescript/packages/auth | ||
| pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false | ||
| # Load it here, on musl: readelf only infers that it will load, | ||
| # and this also catches a missing runtime library or an | ||
| # unresolved symbol, in every build, release builds included. | ||
| node -e "require(process.argv[1])" "$PWD/stack-auth-node.linux-x64-musl.node" | ||
| ' | ||
|
|
||
| # The build must leave the tracked tree alone: `native.d.ts` is | ||
| # regenerated and has to equal the committed copy, and nothing else may | ||
| # change. | ||
|
|
@@ -130,6 +168,37 @@ jobs: | |
| mv "stack-auth-node.${PLATFORM}.node" "platforms/${PLATFORM}/" | ||
| test -s "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node" | ||
|
|
||
| # 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. | ||
| # Into a variable, never into `grep -q`, so readelf cannot die on EPIPE. | ||
| - name: Check which C library the binary links | ||
| if: ${{ runner.os == 'Linux' }} | ||
| working-directory: languages/typescript/packages/auth | ||
| env: | ||
| PLATFORM: ${{ matrix.platform }} | ||
| run: | | ||
| set -euo pipefail | ||
| dynamic=$(readelf -d "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node") | ||
| case "$PLATFORM" in | ||
| linux-x64-gnu|linux-arm64-gnu) | ||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Change before merge: the Impact: A binary with no C library Evidence: The gnu arm on line 178 requires Fix: Require the musl C library after the glibc rejection. On Alpine x86_64 the shared library name is 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 Found by 2 models: claude, codex
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| echo "::error::linux-x64-musl links glibc — it is the gnu binary" | ||
| exit 1 | ||
| fi | ||
| # A static binary has no libc entry at all, and Node.js cannot | ||
| # load it as a native module, so musl must be named. | ||
| grep -q 'NEEDED.*libc\.musl-' <<< "$dynamic" || { | ||
| echo "::error::linux-x64-musl has no musl libc NEEDED entry — it is linked statically" | ||
| exit 1; } ;; | ||
| *) | ||
| echo "::error::no C library rule for $PLATFORM"; exit 1 ;; | ||
| esac | ||
| echo "$PLATFORM: links the expected C library" | ||
|
|
||
| # `npm pack`, not `pnpm pack`: a platform package has no `workspace:` | ||
| # dependency to rewrite. The wrapper does, and is packed with pnpm below. | ||
| - name: Pack the platform package | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,7 +51,8 @@ describe('_build-auth-artifacts.yml', () => { | |
|
|
||
| it('builds each binding without writing over the committed loader', () => { | ||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Change before merge: this test now expects two Impact: 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| for (const flag of [ | ||
| '--platform', | ||
| '--release', | ||
|
|
@@ -60,17 +61,107 @@ describe('_build-auth-artifacts.yml', () => { | |
| '--dts native.d.ts', | ||
| '--js false', | ||
| ]) { | ||
| expect(build[0]).toContain(flag) | ||
| for (const run of build) expect(run).toContain(flag) | ||
| } | ||
| }) | ||
|
|
||
| it('fails the leg when the build changed a tracked file', () => { | ||
| it('fails the leg when either build changed a tracked file', () => { | ||
| const all = runs(binaries) | ||
| const build = all.findIndex((run) => /\bnapi build\b/.test(run)) | ||
| const builds = all | ||
| .map((_, 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(check).toBeGreaterThan(build) | ||
| expect(builds).toHaveLength(2) | ||
| for (const build of builds) expect(check).toBeGreaterThan(build) | ||
| }) | ||
|
|
||
| it('builds the musl binding inside Alpine, from an image pinned by digest', () => { | ||
| // Built on the Ubuntu runner, the musl binary linked glibc and failed to | ||
| // load on musl. Alpine is a musl system, so its compiler links musl. | ||
| const steps = binaries?.steps ?? [] | ||
| const musl = steps.filter((step) => | ||
| String(step?.if ?? '').includes("== 'linux-x64-musl'"), | ||
| ) | ||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Change before merge: this test checks the value of Impact: An edit can write Fix: Assert that 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in Keeping the digest current, and pinning the |
||
| // The pinned image is the one that runs, not a mutable tag. | ||
| expect(run).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/) | ||
| expect(run).not.toMatch(/\bnode:\d+-alpine(?!@)/) | ||
| expect(run).toContain('RUSTFLAGS="-C target-feature=-crt-static"') | ||
| // The container loads the binary it built, on musl. | ||
| expect(run).toMatch( | ||
| /napi build[\s\S]*node -e "require\(process\.argv\[1\]\)" "\$PWD\/stack-auth-node\.linux-x64-musl\.node"/, | ||
| ) | ||
| // The container hands its files back even when the build fails. | ||
| expect(run).toMatch(/^\s*trap "chown -R .*\/build" EXIT$/m) | ||
| // The host steps that only the host build uses skip the musl leg. | ||
| for (const name of [ | ||
| 'Add the Rust target', | ||
| 'Install node-gyp', | ||
| 'Install dependencies', | ||
| 'Build the native binding', | ||
| ]) { | ||
| const step = steps.find((s) => s?.name === name) | ||
| expect(String(step?.if ?? '')).toContain("!= 'linux-x64-musl'") | ||
| } | ||
| }) | ||
|
|
||
| it('checks the C library of every Linux binary before it is packed', () => { | ||
| // Without this, only a hand-run auth-preflight sees a glibc-linked musl | ||
| // binary, and a release would publish it. | ||
| const steps = binaries?.steps ?? [] | ||
| const check = steps.findIndex((step) => | ||
| String(step?.run ?? '').includes('readelf -d'), | ||
| ) | ||
| const pack = steps.findIndex((step) => | ||
| /\bnpm pack\b/.test(String(step?.run ?? '')), | ||
| ) | ||
| expect(check).toBeGreaterThan(-1) | ||
| expect(check).toBeLessThan(pack) | ||
| const run = String(steps[check].run) | ||
| expect(String(steps[check].if)).toContain("runner.os == 'Linux'") | ||
| expect(run).toMatch( | ||
| /linux-x64-gnu\|linux-arm64-gnu\)[\s\S]*libc\\\.so\\\.6/, | ||
| ) | ||
| expect(run).toContain('linux-x64-musl links glibc') | ||
| // A static binary has no libc entry, so musl must be named, not only | ||
| // glibc refused. | ||
| expect(run).toMatch(/linux-x64-musl\)[\s\S]*libc\\\.musl-/) | ||
| // Every rejection fails the job, so the binary is never packed. | ||
| const errors = run.match(/::error::/g) ?? [] | ||
| expect(errors.length).toBeGreaterThan(0) | ||
| expect((run.match(/exit 1/g) ?? []).length).toBe(errors.length) | ||
| // Every Linux platform in the matrix has a rule. | ||
| const linux = (binaries?.strategy?.matrix?.include ?? []) | ||
| .filter((leg) => String(leg.os).startsWith('ubuntu')) | ||
| .map((leg) => leg.platform) | ||
| expect(linux.length).toBeGreaterThan(0) | ||
| for (const platform of linux) { | ||
| expect(run).toMatch(new RegExp(`(^|[|\\s])${platform}[|)]`, 'm')) | ||
| } | ||
| }) | ||
|
|
||
| it('auth-preflight requires musl, and loads the musl artifact inside Alpine', () => { | ||
| const preflight = readWorkflow('.github/workflows/auth-preflight.yml') | ||
| const steps = preflight?.jobs?.smoke?.steps ?? [] | ||
| const verify = steps.map((step) => String(step?.run ?? '')).join('\n') | ||
| expect(verify).toMatch(/linux-x64-musl\)[\s\S]*libc\\\.musl-/) | ||
| // The host smoke test installs only the runner's own platform, so without | ||
| // this step no job loads the musl binary. | ||
| const alpine = steps.find((step) => | ||
| /\bdocker run\b/.test(String(step?.run ?? '')), | ||
| ) | ||
| expect(alpine).toBeDefined() | ||
| expect(String(alpine.run)).toContain('linux-x64-musl') | ||
| expect(String(alpine.run)).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/) | ||
| // The same image as the build, so the load test matches the build. | ||
| const build = (binaries?.steps ?? []).find( | ||
| (step) => step?.env?.ALPINE_NODE_IMAGE, | ||
| ) | ||
| expect(alpine.env?.ALPINE_NODE_IMAGE).toBe(build?.env?.ALPINE_NODE_IMAGE) | ||
| }) | ||
|
|
||
| it('packs the wrapper with pnpm, which rewrites its workspace peers', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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-97andffi-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 inscripts/__tests__/auth-build-artifacts.test.mjsreads 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 thecaseblock out of each file and asserts they match. The repository already does this for duplicated workflow logic inscripts/__tests__/eql-workflow-filters.test.mjs.Found by 1 model: claude
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is now Linear issue CIP-4284. It moves the rules into one script that all three workflows call, and tests that script directly.