Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 76 additions & 7 deletions .github/workflows/_build-auth-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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.
Expand All @@ -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.

Copy link
Copy Markdown

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-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

Copy link
Copy Markdown
Contributor Author

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.

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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
Expand Down
30 changes: 29 additions & 1 deletion .github/workflows/auth-preflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ jobs:
echo "::error::linux-x64-musl links glibc — it is the gnu binary"
exit 1
fi
echo "linux-x64-musl: no glibc NEEDED entry" ;;
grep -q 'NEEDED.*libc\.musl-' <<< "$dynamic" || {
echo "::error::linux-x64-musl has no musl libc NEEDED entry — it is linked statically"
exit 1; }
echo "linux-x64-musl: links musl, not glibc" ;;
esac
checked=$((checked + 1))
done
Expand Down Expand Up @@ -130,3 +133,28 @@ jobs:
console.log('smoke OK')
EOF
node smoke.mjs

# The host step above installs only linux-x64-gnu, the runner's own
# platform. The musl binary loads only where musl is the C library, so it
# is installed and loaded inside Alpine, from the image the build uses.
- name: Smoke-test the musl artifact inside Alpine
env:
ALPINE_NODE_IMAGE: node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
run: |
set -euo pipefail
wrapper=$(basename "$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-[0-9]*.tgz)")
musl=$(basename "$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-linux-x64-musl-*.tgz)")
docker run --rm -v "$GITHUB_WORKSPACE/auth-dist:/dist:ro" \
-e WRAPPER="$wrapper" -e MUSL="$musl" \
"$ALPINE_NODE_IMAGE" sh -euc '
mkdir -p /tmp/smoke && cd /tmp/smoke
echo "{\"name\":\"smoke\",\"version\":\"1.0.0\",\"private\":true}" > package.json
npm install --no-audit --no-fund "/dist/$WRAPPER" "/dist/$MUSL"
node -e "
const auth = require(\"@cipherstash/auth\")
for (const name of [\"AccessKeyStrategy\", \"AutoStrategy\", \"OidcFederationStrategy\"]) {
if (typeof auth[name] !== \"function\") throw new Error(\"no \" + name)
}
console.log(\"musl smoke OK\")
"
'
101 changes: 96 additions & 5 deletions scripts/__tests__/auth-build-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea9dc4a0. The test now checks that Verify the build changed no tracked file comes after both napi build steps.

for (const flag of [
'--platform',
'--release',
Expand All @@ -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}$/)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// 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', () => {
Expand Down
Loading