From 55c105824a2617cb70a78e764cfc2383c101c211 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 09:32:26 +1000 Subject: [PATCH 1/5] ci: keep one copy of the Linux C library rules The rules for which C library a Linux release binary must link were copied into _build-auth-artifacts.yml, auth-preflight.yml and ffi-preflight.yml, and nothing kept the copies the same. The copy in ffi-preflight.yml had already drifted: it refused glibc for the musl binary but did not require musl, so it accepted a static binary. scripts/check-c-library.sh now holds the rules, and each of the three workflows calls it. The preflights check out the built commit's scripts/ before they download the tarballs. scripts/__tests__/check-c-library.test.mjs runs the script with the real readelf on a glibc, a musl and two static ELF files, which the test writes itself. No musl compiler is on the runners, and a checked-in binary is a file no reviewer can read. It also checks that every Linux platform package has a rule, that an unknown platform fails, and that no workflow keeps a copy of the rules. lint-release.yml now runs shellcheck on the script. Refs: CIP-4284 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/_build-auth-artifacts.yml | 27 +- .github/workflows/auth-preflight.yml | 31 +- .github/workflows/ffi-preflight.yml | 40 +-- .github/workflows/lint-release.yml | 11 +- .../__tests__/auth-build-artifacts.test.mjs | 53 +-- scripts/__tests__/check-c-library.test.mjs | 330 ++++++++++++++++++ scripts/check-c-library.sh | 41 +++ 7 files changed, 440 insertions(+), 93 deletions(-) create mode 100644 scripts/__tests__/check-c-library.test.mjs create mode 100755 scripts/check-c-library.sh diff --git a/.github/workflows/_build-auth-artifacts.yml b/.github/workflows/_build-auth-artifacts.yml index c92ba3ab0..913171280 100644 --- a/.github/workflows/_build-auth-artifacts.yml +++ b/.github/workflows/_build-auth-artifacts.yml @@ -170,34 +170,13 @@ jobs: # 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. + # auth-preflight first. The rules are in scripts/check-c-library.sh, which + # every workflow that checks a Linux binary runs. - 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 - 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" + run: scripts/check-c-library.sh "$PLATFORM" "languages/typescript/packages/auth/platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node" # `npm pack`, not `pnpm pack`: a platform package has no `workspace:` # dependency to rewrite. The wrapper does, and is packed with pnpm below. diff --git a/.github/workflows/auth-preflight.yml b/.github/workflows/auth-preflight.yml index aa50f0e77..9f7f6a95b 100644 --- a/.github/workflows/auth-preflight.yml +++ b/.github/workflows/auth-preflight.yml @@ -40,6 +40,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: + # For scripts/check-c-library.sh, from the commit the binaries were built + # from, so this job applies the rules the build applied. Before the + # download, because a checkout empties the directory it checks out into. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + sparse-checkout: scripts + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: auth-tarballs @@ -79,24 +88,10 @@ jobs: [[ "$desc" =~ ${EXPECT[$platform]} ]] || { echo "::error::$platform binary is '$desc', expected ${EXPECT[$platform]}" exit 1; } - # `file` cannot tell gnu from musl; the dynamic section can. Into a - # variable, never into `grep -q` (see ffi-preflight.yml). - case "$platform" in - linux-x64-gnu|linux-arm64-gnu) - dynamic=$(readelf -d "$binary") - grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" || { - echo "::error::$platform does not link glibc"; exit 1; } ;; - linux-x64-musl) - dynamic=$(readelf -d "$binary") - 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; } - echo "linux-x64-musl: links musl, not glibc" ;; - esac + # `file` cannot tell gnu from musl; the C library check can. + if [[ "$platform" == linux-* ]]; then + "$GITHUB_WORKSPACE/scripts/check-c-library.sh" "$platform" "$binary" + fi checked=$((checked + 1)) done test "$checked" -eq "${#EXPECT[@]}" || { diff --git a/.github/workflows/ffi-preflight.yml b/.github/workflows/ffi-preflight.yml index 729e20760..ac5379683 100644 --- a/.github/workflows/ffi-preflight.yml +++ b/.github/workflows/ffi-preflight.yml @@ -42,6 +42,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: + # For scripts/check-c-library.sh, from the commit the binaries were built + # from, so this job applies the rules the build applied. Before the + # download, because a checkout empties the directory it checks out into. + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + sparse-checkout: scripts + - uses: actions/download-artifact@v4 with: name: ffi-tarballs @@ -94,31 +103,11 @@ jobs: # `file` reads linux-x64-gnu and linux-x64-musl identically — both # are "ELF 64-bit … x86-64" — so the check above passes if the two # are swapped, and the failure lands on an Alpine user at runtime. - # The ABI is only visible in the dynamic section: the gnu build - # links libc.so.6, the musl build does not (RUSTFLAGS drops - # crt-static, so it stays dynamic against musl's own libc). - # - # `readelf` into a VARIABLE, never into `grep -q` — the same - # SIGPIPE-under-pipefail trap documented in - # _build-ffi-artifacts.yml, and here the musl branch fails OPEN. - # A poisoned pipeline is non-zero, the `if` below is therefore - # false, and the check reports "no glibc NEEDED entry" for the - # exact binary it exists to reject. A dynamic section is far longer - # than a tarball listing, so the writer is all but certain to still - # be writing when grep leaves. - case "$platform" in - linux-x64-gnu|linux-arm64-gnu) - dynamic=$(readelf -d x/package/index.node) - grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" || { - echo "::error::$platform does not link glibc"; exit 1; } ;; - linux-x64-musl) - dynamic=$(readelf -d x/package/index.node) - if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then - echo "::error::linux-x64-musl links glibc — it is the gnu binary" - exit 1 - fi - echo "linux-x64-musl: no glibc NEEDED entry" ;; - esac + # The C library is visible only in the dynamic section, which + # scripts/check-c-library.sh reads. + if [[ "$platform" == linux-* ]]; then + "$GITHUB_WORKSPACE/scripts/check-c-library.sh" "$platform" x/package/index.node + fi checked=$((checked + 1)) done # Every platform in the table above, or the loop skipped one and @@ -160,3 +149,4 @@ jobs: console.log('smoke OK') EOF node smoke.mjs + diff --git a/.github/workflows/lint-release.yml b/.github/workflows/lint-release.yml index 4a36c5a3e..adde4c933 100644 --- a/.github/workflows/lint-release.yml +++ b/.github/workflows/lint-release.yml @@ -19,8 +19,8 @@ name: Lint release tooling on: pull_request: # Exactly what the job reads: the workflows actionlint is pointed at, the - # config it resolves the Blacksmith label from, and the two shell scripts - # the last two steps run. The filter also named scripts/release-gate.mjs, + # config it resolves the Blacksmith label from, and the shell scripts the + # later steps check and run. The filter also named scripts/release-gate.mjs, # scripts/ffi-release-matrix.mjs, scripts/lint-no-workflow-caching.mjs and # package.json — none of which this job looks at, since it deliberately does # not run `test:scripts` (see the job comment). Editing one booted a runner @@ -43,6 +43,7 @@ on: - .github/workflows/lint-release.yml - .github/actionlint.yaml - packages/eql/tasks/release/*.sh + - scripts/check-c-library.sh workflow_dispatch: {} permissions: @@ -113,3 +114,9 @@ jobs: - name: prepare-bindings-assets validation unit test run: bash packages/eql/tasks/release/prepare-bindings-assets.test.sh + + # Every release build runs it on each Linux binary before packing, so a + # shell mistake in it fails a release mid-build. Its tests run in + # `test:scripts`. + - name: shellcheck (C library check) + run: shellcheck scripts/check-c-library.sh diff --git a/scripts/__tests__/auth-build-artifacts.test.mjs b/scripts/__tests__/auth-build-artifacts.test.mjs index 899723a65..da81fd261 100644 --- a/scripts/__tests__/auth-build-artifacts.test.mjs +++ b/scripts/__tests__/auth-build-artifacts.test.mjs @@ -30,6 +30,8 @@ const workflow = readWorkflow(WORKFLOW) const binaries = workflow?.jobs?.binaries const wrapper = workflow?.jobs?.wrapper const runs = (job) => (job?.steps ?? []).map((step) => String(step?.run ?? '')) +/** A GitHub Actions expression, as the parsed workflow holds it. */ +const gha = (expression) => `\${{ ${expression} }}` const PLATFORMS = readdirSync(join(AUTH, 'platforms'), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -112,44 +114,47 @@ describe('_build-auth-artifacts.yml', () => { 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. + // binary, and a release would publish it. The rules themselves, and that + // every Linux platform package has one, are check-c-library.test.mjs's. const steps = binaries?.steps ?? [] const check = steps.findIndex((step) => - String(step?.run ?? '').includes('readelf -d'), + String(step?.run ?? '').includes('scripts/check-c-library.sh'), + ) + const place = steps.findIndex( + (step) => step?.name === 'Place the binding in its platform package', ) const pack = steps.findIndex((step) => /\bnpm pack\b/.test(String(step?.run ?? '')), ) - expect(check).toBeGreaterThan(-1) + expect(place).toBeGreaterThan(-1) + expect(check).toBeGreaterThan(place) 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/, + // The binary that is packed, under the leg's own platform name. + expect(steps[check].env?.PLATFORM).toBe(gha('matrix.platform')) + expect(String(steps[check].run)).toMatch( + /check-c-library\.sh "\$PLATFORM" "languages\/typescript\/packages\/auth\/platforms\/\$\{PLATFORM\}\/stack-auth-node\.\$\{PLATFORM\}\.node"/, ) - 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', () => { + it('auth-preflight checks the C library, 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-/) + expect(verify).toMatch( + /if \[\[ "\$platform" == linux-\* \]\]; then\s+"\$GITHUB_WORKSPACE\/scripts\/check-c-library\.sh" "\$platform" "\$binary"/, + ) + // The script comes from the commit that was built, and the checkout comes + // first, because a checkout empties the directory it checks out into. + const checkout = steps.findIndex((step) => + String(step?.uses ?? '').startsWith('actions/checkout@'), + ) + const download = steps.findIndex((step) => + String(step?.uses ?? '').startsWith('actions/download-artifact@'), + ) + expect(checkout).toBeGreaterThan(-1) + expect(checkout).toBeLessThan(download) + expect(steps[checkout].with?.ref).toBe(gha('inputs.ref')) // 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) => diff --git a/scripts/__tests__/check-c-library.test.mjs b/scripts/__tests__/check-c-library.test.mjs new file mode 100644 index 000000000..526348e38 --- /dev/null +++ b/scripts/__tests__/check-c-library.test.mjs @@ -0,0 +1,330 @@ +import { spawnSync } from 'node:child_process' +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow, workflowFiles } from './lib/workflows.mjs' + +/** + * `scripts/check-c-library.sh` is the one copy of the rules for which C + * library each Linux release binary must link. It used to be three copies, in + * `_build-auth-artifacts.yml`, `auth-preflight.yml` and `ffi-preflight.yml`, + * and nothing kept them the same. + * + * THE FIXTURES ARE WRITTEN HERE, NOT COMPILED AND NOT CHECKED IN. No musl + * compiler is on the CI runners or on a developer's machine, and a checked-in + * binary is a file no reviewer can read. The script reads one thing from a + * binary, the NEEDED entries of its dynamic section, and it reads them through + * the real `readelf`. So a minimal ELF file whose dynamic section names the + * libraries a real gnu or musl build names is the same input to it, and every + * byte of it is in `elf()` below. + */ + +const SCRIPT = join(REPO_ROOT, 'scripts/check-c-library.sh') + +/** + * A minimal 64-bit little-endian x86-64 ELF file. + * + * With `needed`, a shared object whose dynamic section lists those libraries, + * as a native module does. With `needed: null`, an executable with no dynamic + * section at all, which is what a statically linked binary has. + * + * One loadable segment maps the whole file at address 0, so every address in + * the dynamic section equals its file offset. readelf resolves the NEEDED + * names through DT_STRTAB, and falls back to the `.dynstr` section header. + */ +function elf({ needed }) { + const EHDR = 64 + const PHDR = 56 + const SHDR = 64 + const align8 = (n) => Math.ceil(n / 8) * 8 + const dynamic = needed !== null + + const dynstr = Buffer.from(`\0${needed?.join('\0') ?? ''}\0`) + const names = ['', '.dynstr', '.dynamic', '.shstrtab'] + const sections = dynamic ? names : ['', '.shstrtab'] + const shstrtab = Buffer.from(`${sections.join('\0')}\0`) + const nameOffset = (name) => shstrtab.indexOf(`\0${name}\0`) + 1 + + const phnum = dynamic ? 2 : 1 + const dynstrOff = EHDR + PHDR * phnum + const dynOff = align8(dynstrOff + (dynamic ? dynstr.length : 0)) + const entries = dynamic + ? [ + ...needed.map((lib) => [1n, BigInt(dynstr.indexOf(`\0${lib}\0`) + 1)]), + [5n, BigInt(dynstrOff)], // DT_STRTAB + [10n, BigInt(dynstr.length)], // DT_STRSZ + [0n, 0n], // DT_NULL + ] + : [] + const dynSize = entries.length * 16 + const shstrOff = dynOff + dynSize + const shOff = align8(shstrOff + shstrtab.length) + const shnum = sections.length + const file = Buffer.alloc(shOff + SHDR * shnum) + + // ELF header + file.write('\x7fELF', 0, 'latin1') + file[4] = 2 // ELFCLASS64 + file[5] = 1 // ELFDATA2LSB + file[6] = 1 // EV_CURRENT + file.writeUInt16LE(dynamic ? 3 : 2, 16) // ET_DYN or ET_EXEC + file.writeUInt16LE(62, 18) // EM_X86_64 + file.writeUInt32LE(1, 20) + file.writeBigUInt64LE(BigInt(EHDR), 32) // e_phoff + file.writeBigUInt64LE(BigInt(shOff), 40) // e_shoff + file.writeUInt16LE(EHDR, 52) + file.writeUInt16LE(PHDR, 54) + file.writeUInt16LE(phnum, 56) + file.writeUInt16LE(SHDR, 58) + file.writeUInt16LE(shnum, 60) + file.writeUInt16LE(shnum - 1, 62) // e_shstrndx: .shstrtab is last + + const phdr = (i, { type, flags, offset, size, align }) => { + const at = EHDR + PHDR * i + file.writeUInt32LE(type, at) + file.writeUInt32LE(flags, at + 4) + file.writeBigUInt64LE(BigInt(offset), at + 8) // p_offset + file.writeBigUInt64LE(BigInt(offset), at + 16) // p_vaddr + file.writeBigUInt64LE(BigInt(offset), at + 24) // p_paddr + file.writeBigUInt64LE(BigInt(size), at + 32) // p_filesz + file.writeBigUInt64LE(BigInt(size), at + 40) // p_memsz + file.writeBigUInt64LE(BigInt(align), at + 48) + } + // PT_LOAD, readable, over the whole file. + phdr(0, { type: 1, flags: 4, offset: 0, size: file.length, align: 0x1000 }) + + const shdr = ( + i, + { name, type, flags, offset, size, link, align, entsize }, + ) => { + const at = shOff + SHDR * i + file.writeUInt32LE(nameOffset(name), at) + file.writeUInt32LE(type, at + 4) + file.writeBigUInt64LE(BigInt(flags), at + 8) + file.writeBigUInt64LE(BigInt(flags & 2 ? offset : 0), at + 16) // sh_addr + file.writeBigUInt64LE(BigInt(offset), at + 24) + file.writeBigUInt64LE(BigInt(size), at + 32) + file.writeUInt32LE(link, at + 40) + file.writeBigUInt64LE(BigInt(align), at + 48) + file.writeBigUInt64LE(BigInt(entsize), at + 56) + } + + if (dynamic) { + dynstr.copy(file, dynstrOff) + entries.forEach(([tag, value], i) => { + file.writeBigInt64LE(tag, dynOff + 16 * i) + file.writeBigUInt64LE(value, dynOff + 16 * i + 8) + }) + // PT_DYNAMIC, readable and writable. + phdr(1, { type: 2, flags: 6, offset: dynOff, size: dynSize, align: 8 }) + // SHT_STRTAB, SHF_ALLOC + shdr(1, { + name: '.dynstr', + type: 3, + flags: 2, + offset: dynstrOff, + size: dynstr.length, + link: 0, + align: 1, + entsize: 0, + }) + // SHT_DYNAMIC, SHF_WRITE | SHF_ALLOC, linked to .dynstr + shdr(2, { + name: '.dynamic', + type: 6, + flags: 3, + offset: dynOff, + size: dynSize, + link: 1, + align: 8, + entsize: 16, + }) + } + shstrtab.copy(file, shstrOff) + shdr(shnum - 1, { + name: '.shstrtab', + type: 3, + flags: 0, + offset: shstrOff, + size: shstrtab.length, + link: 0, + align: 1, + entsize: 0, + }) + return file +} + +const dir = mkdtempSync(join(tmpdir(), 'check-c-library-')) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +/** The libraries a Rust cdylib names on each C library, as readelf lists them. */ +const FIXTURES = { + gnu: ['libgcc_s.so.1', 'libm.so.6', 'libc.so.6', 'ld-linux-x86-64.so.2'], + musl: ['libgcc_s.so.1', 'libc.musl-x86_64.so.1'], + // A binary with a dynamic section that names no C library: a C library + // linked in statically, with only libgcc left dynamic. + 'no-libc': ['libgcc_s.so.1'], + // No dynamic section at all. + static: null, +} +const fixture = Object.fromEntries( + Object.entries(FIXTURES).map(([name, needed]) => { + const path = join(dir, `${name}.node`) + writeFileSync(path, elf({ needed })) + return [name, path] + }), +) + +function check(...args) { + const result = spawnSync(SCRIPT, args, { encoding: 'utf8' }) + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + } +} + +// readelf is GNU binutils, which every Linux runner has and macOS does not. +// Skipped only outside CI, so CI cannot pass by checking nothing. +const hasReadelf = spawnSync('readelf', ['--version']).status === 0 + +describe.skipIf(!hasReadelf && !process.env.CI)('check-c-library.sh', () => { + it('reads the fixtures the way it reads a real binary', () => { + const gnu = spawnSync('readelf', ['-d', fixture.gnu], { encoding: 'utf8' }) + expect(gnu.status).toBe(0) + for (const lib of FIXTURES.gnu) { + expect(gnu.stdout).toMatch( + new RegExp(`\\(NEEDED\\)\\s+Shared library: \\[${lib}\\]`), + ) + } + const stat = spawnSync('readelf', ['-d', fixture.static], { + encoding: 'utf8', + }) + expect(stat.status).toBe(0) + expect(stat.stdout).toContain('There is no dynamic section in this file.') + }) + + const ACCEPTS = { + 'linux-x64-gnu': 'gnu', + 'linux-arm64-gnu': 'gnu', + 'linux-x64-musl': 'musl', + } + + const cases = Object.entries(ACCEPTS).flatMap(([platform, accepted]) => + Object.keys(FIXTURES).map((binary) => ({ + platform, + binary, + ok: binary === accepted, + })), + ) + + it.each(cases)( + '$platform with the $binary binary: accepted is $ok', + ({ platform, binary, ok }) => { + const { status, output } = check(platform, fixture[binary]) + if (ok) { + expect(status, output).toBe(0) + expect(output).toContain(`${platform}: links the expected C library`) + } else { + expect(status, output).toBe(1) + // A failure names its cause in the job log, as an annotation. + expect(output).toMatch(new RegExp(`^::error::.*${platform}`, 'm')) + } + }, + ) + + it('names the cause of each musl rejection', () => { + expect(check('linux-x64-musl', fixture.gnu).output).toContain('links glibc') + for (const binary of ['static', 'no-libc']) { + expect(check('linux-x64-musl', fixture[binary]).output).toContain( + 'linked statically', + ) + } + }) + + it.each([ + 'darwin-x64', + 'win32-x64-msvc', + 'linux-arm64-musl', + 'linux-x64', + 'linux-x64-gnu ', + ])('rejects %j, which has no rule, whatever the binary', (platform) => { + for (const binary of ['gnu', 'musl']) { + const { status, output } = check(platform, fixture[binary]) + expect(status, output).toBe(1) + expect(output).toContain(`::error::no C library rule for ${platform}`) + } + }) + + it('fails when the binary is missing or is not an ELF file', () => { + const notElf = join(dir, 'not-elf.node') + writeFileSync(notElf, 'not an ELF file\n') + for (const binary of [join(dir, 'absent.node'), notElf]) { + expect(check('linux-x64-gnu', binary).status).not.toBe(0) + } + }) + + it('fails when an argument is missing', () => { + expect(check().status).not.toBe(0) + expect(check('linux-x64-gnu').output).toContain('usage:') + }) + + it('has a rule for every Linux platform package that the release publishes', () => { + // Each platform name ends in its C library, so a new Linux platform + // package is checked here against the binary it must accept. + const linux = ['auth', 'protect-ffi'].flatMap((pkg) => + readdirSync( + join(REPO_ROOT, 'languages/typescript/packages', pkg, 'platforms'), + ).filter((name) => name.startsWith('linux-')), + ) + expect(linux.length).toBeGreaterThan(0) + for (const platform of new Set(linux)) { + const libc = platform.split('-').at(-1) + const { status, output } = check(platform, fixture[libc]) + expect(status, output).toBe(0) + } + }) +}) + +describe('the workflows that check a Linux binary', () => { + const CALLERS = [ + '.github/workflows/_build-auth-artifacts.yml', + '.github/workflows/auth-preflight.yml', + '.github/workflows/ffi-preflight.yml', + ] + + const runs = (file) => + Object.values(readWorkflow(file)?.jobs ?? {}).flatMap((job) => + (job?.steps ?? []).map((step) => String(step?.run ?? '')), + ) + + it.each(CALLERS)('%s calls the script', (file) => { + expect(runs(file).some((run) => run.includes('check-c-library.sh'))).toBe( + true, + ) + }) + + it('keep no copy of the rules of their own', () => { + // Discovery, not the list above: a copy added to any workflow fails here. + const copies = workflowFiles().filter((file) => + runs(file).some((run) => + /\breadelf\s+-|libc\\?\.musl|libc\\?\.so\\?\.6/.test(run), + ), + ) + expect(copies).toEqual([]) + }) + + it('is executable, so a workflow can run it by path', () => { + // A CI checkout takes the mode from git, so this reads the committed mode. + expect(statSync(SCRIPT).mode & 0o111).toBe(0o111) + expect(readFileSync(SCRIPT, 'utf8')).toMatch(/^#!\/usr\/bin\/env bash\n/) + }) +}) diff --git a/scripts/check-c-library.sh b/scripts/check-c-library.sh new file mode 100755 index 000000000..49de35ea2 --- /dev/null +++ b/scripts/check-c-library.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Check that a Linux release binary links the C library its platform claims. +# +# The one copy of these rules: both build workflows run it on each Linux +# binary before packing, and both preflights run it on the packed tarballs. +# +# `file` reads gnu and musl x86-64 binaries identically, so the C library is +# read from the NEEDED entries, the libraries the loader must load with it. +# A musl binary must name musl, not only avoid glibc: a statically linked +# binary names no C library, and Node.js cannot load it as a native module. +# +# A platform with no rule fails, so a new Linux platform cannot ship unchecked. +# +# Usage: check-c-library.sh +set -euo pipefail + +usage='usage: check-c-library.sh ' +platform=${1:?$usage} +binary=${2:?$usage} + +# Into a variable, never piped into `grep -q`: grep exits at the first match, +# readelf takes SIGPIPE, and under pipefail the musl glibc test fails open. +dynamic=$(readelf -d "$binary") + +needs() { grep -q "NEEDED.*$1" <<< "$dynamic"; } + +case "$platform" in + linux-x64-gnu|linux-arm64-gnu) + needs 'libc\.so\.6' || { + echo "::error::$platform does not link glibc"; exit 1; } ;; + linux-x64-musl) + if needs 'libc\.so\.6'; then + echo "::error::$platform links glibc — it is the gnu binary"; exit 1 + fi + needs 'libc\.musl-' || { + echo "::error::$platform 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" From 809fe45b1f517bc72361b6e151f374e57baf46cd Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 09:33:19 +1000 Subject: [PATCH 2/5] ci(ffi): check the C library of each Linux protect-ffi binary before 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) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/_build-ffi-artifacts.yml | 10 +++++ scripts/__tests__/check-c-library.test.mjs | 1 + .../__tests__/ffi-build-artifacts.test.mjs | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 scripts/__tests__/ffi-build-artifacts.test.mjs diff --git a/.github/workflows/_build-ffi-artifacts.yml b/.github/workflows/_build-ffi-artifacts.yml index b11178ba4..b83c4d006 100644 --- a/.github/workflows/_build-ffi-artifacts.yml +++ b/.github/workflows/_build-ffi-artifacts.yml @@ -269,6 +269,16 @@ jobs: -o "platforms/${PLATFORM}/index.node" < "${BUILD_LOG}" test -s "platforms/${PLATFORM}/index.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 + # ffi-preflight first. The rules are in scripts/check-c-library.sh, which + # every workflow that checks a Linux binary runs. + - name: Check which C library the binary links + if: ${{ runner.os == 'Linux' }} + env: + PLATFORM: ${{ matrix.cfg.platform }} + run: scripts/check-c-library.sh "$PLATFORM" "languages/typescript/packages/protect-ffi/platforms/${PLATFORM}/index.node" + # `pnpm pack` writes into the packed package's own directory by default, # and `--pack-destination` resolves relative to `--dir` rather than to the # CWD (verified). Packing to the default location and moving the result diff --git a/scripts/__tests__/check-c-library.test.mjs b/scripts/__tests__/check-c-library.test.mjs index 526348e38..647f919b6 100644 --- a/scripts/__tests__/check-c-library.test.mjs +++ b/scripts/__tests__/check-c-library.test.mjs @@ -297,6 +297,7 @@ describe.skipIf(!hasReadelf && !process.env.CI)('check-c-library.sh', () => { describe('the workflows that check a Linux binary', () => { const CALLERS = [ '.github/workflows/_build-auth-artifacts.yml', + '.github/workflows/_build-ffi-artifacts.yml', '.github/workflows/auth-preflight.yml', '.github/workflows/ffi-preflight.yml', ] diff --git a/scripts/__tests__/ffi-build-artifacts.test.mjs b/scripts/__tests__/ffi-build-artifacts.test.mjs new file mode 100644 index 000000000..0c44f6e76 --- /dev/null +++ b/scripts/__tests__/ffi-build-artifacts.test.mjs @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `_build-ffi-artifacts.yml` builds the seven `@cipherstash/protect-ffi` + * tarballs that `release.yml`'s `publish-ffi` uploads. Every Linux binary's C + * library is checked before it is packed. Without the check, only a hand-run + * ffi-preflight sees a glibc-linked musl binary, which fails to load on musl + * systems such as Alpine Linux, and a release would publish it. + */ + +const workflow = readWorkflow('.github/workflows/_build-ffi-artifacts.yml') +const steps = workflow?.jobs?.binaries?.steps ?? [] +const runOf = (step) => String(step?.run ?? '') +const named = (name) => steps.findIndex((step) => step?.name === name) + +/** A GitHub Actions expression, as the parsed workflow holds it. */ +const gha = (expression) => `\${{ ${expression} }}` + +describe('_build-ffi-artifacts.yml', () => { + it('checks the C library of every Linux binary before it is packed', () => { + const check = steps.findIndex((step) => + runOf(step).includes('scripts/check-c-library.sh'), + ) + const place = named('Place the binding in its platform package') + const pack = named('Pack the platform package') + expect(place).toBeGreaterThan(-1) + expect(check).toBeGreaterThan(place) + expect(check).toBeLessThan(pack) + expect(String(steps[check].if)).toContain("runner.os == 'Linux'") + // The binary that is packed, under the leg's own platform name. + expect(steps[check].env?.PLATFORM).toBe(gha('matrix.cfg.platform')) + expect(runOf(steps[check])).toMatch( + /check-c-library\.sh "\$PLATFORM" "languages\/typescript\/packages\/protect-ffi\/platforms\/\$\{PLATFORM\}\/index\.node"/, + ) + }) +}) From 42cd8ff241ae65e6d4c890106ad755d8d2a7eb17 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 09:34:14 +1000 Subject: [PATCH 3/5] ci(ffi): build the musl protect-ffi binary in Alpine, not with musl.cc _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) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/_build-ffi-artifacts.yml | 117 ++++++++------- .github/workflows/ffi-preflight.yml | 27 +++- .../__tests__/ffi-build-artifacts.test.mjs | 139 +++++++++++++++++- 3 files changed, 222 insertions(+), 61 deletions(-) diff --git a/.github/workflows/_build-ffi-artifacts.yml b/.github/workflows/_build-ffi-artifacts.yml index b83c4d006..9f20b55fa 100644 --- a/.github/workflows/_build-ffi-artifacts.yml +++ b/.github/workflows/_build-ffi-artifacts.yml @@ -109,11 +109,11 @@ jobs: echo "OPENSSL_STATIC=1" >> $env:GITHUB_ENV # The aarch64 linker, for the one platform that cross-compiles to it. - # Scoped to that platform rather than to Linux: `linux-x64-musl` puts - # musl.cc's own `x86_64-linux-musl-gcc` on PATH below and never calls - # this one, and `linux-x64-gnu` is a native x86_64 build — so the other - # two legs were paying an `apt-get update` (full package indexes) plus a - # ~200MB toolchain they do not link against. + # Scoped to that platform rather than to Linux: `linux-x64-musl` builds + # inside Alpine below with Alpine's own compiler, and `linux-x64-gnu` is + # a native x86_64 build — so the other two legs were paying an + # `apt-get update` (full package indexes) plus a ~200MB toolchain they do + # not link against. - name: Install cross-compile toolchain (linux-arm64-gnu) if: ${{ matrix.cfg.platform == 'linux-arm64-gnu' }} run: | @@ -134,6 +134,7 @@ jobs: # the no-caching gate, so not adding a fourth action to that list is worth # something on its own. - name: Add the Rust target + if: ${{ matrix.cfg.platform != 'linux-x64-musl' }} env: CARGO_BUILD_TARGET: ${{ matrix.cfg.target }} run: rustup target add "$CARGO_BUILD_TARGET" @@ -149,6 +150,7 @@ jobs: package-manager-cache: false - name: Install node-gyp + if: ${{ matrix.cfg.platform != 'linux-x64-musl' }} run: npm install -g node-gyp # zig + cargo-zigbuild, pinned in languages/typescript/packages/protect-ffi/mise.toml. Only the @@ -179,65 +181,27 @@ jobs: cache: false - name: Install dependencies + if: ${{ matrix.cfg.platform != 'linux-x64-musl' }} run: pnpm install --frozen-lockfile - name: Build binding + if: ${{ matrix.cfg.platform != 'linux-x64-musl' }} working-directory: languages/typescript/packages/protect-ffi env: CARGO_BUILD_TARGET: ${{ matrix.cfg.target }} NEON_BUILD_PLATFORM: ${{ matrix.cfg.platform }} BUILD_SCRIPT: ${{ matrix.cfg.script }} - # The musl cross toolchain is fetched from musl.cc over plain HTTPS - # with no signature to check, and whatever it hands back LINKS THE - # BINARY that this workflow publishes to npm with provenance — a - # provenance attestation says where a build ran, not that its inputs - # were the intended ones. Upstream's build.yml took the download on - # trust; this pins it. - # - # Trust-on-first-use, and worth being precise about what that buys: - # the digest was taken from two independent fetches of the current - # artifact (2026-08-11), so it does not authenticate musl.cc — it - # makes any later substitution a hard failure instead of a silent one. - # If musl.cc rebuilds the tarball this step fails; re-verify the new - # artifact deliberately and update the digest here, do not delete the - # check to unblock a release. - MUSL_TOOLCHAIN_URL: https://musl.cc/x86_64-linux-musl-native.tgz - MUSL_TOOLCHAIN_SHA256: eb1db6f0f3c2bdbdbfb993d7ef7e2eeef82ac1259f6a6e1757c33a97dbcef3ad # No `--` separator anywhere below: npm strips it, pnpm forwards it # verbatim, and these scripts end in `> cargo.log` — so a forwarded flag # lands after the redirect and cargo rejects it as a positional. run: | set -euo pipefail - # `x86_64-unknown-linux-musl` -> `x86_64-linux-musl-gcc`. Parameter - # expansion rather than upstream's `sed`, and assigned before export - # rather than through it: actionlint runs shellcheck over `run:` - # blocks and the original spelling draws SC2001 and SC2155. - # - # Each linker variable is exported by the branch that reads it. Set - # unconditionally, as upstream had them, both are also set on Windows - # and on the two Darwin legs — where `CARGO_TARGET_..._MUSL_LINKER` - # ends up naming `x86_64-apple-darwin-gcc`, a binary that does not - # exist and that nothing on those platforms reads. - linker="${CARGO_BUILD_TARGET/unknown-/}-gcc" - if [[ "$CARGO_BUILD_TARGET" =~ musl ]]; then - export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER="$linker" - wget -4 -O musl-native.tgz "$MUSL_TOOLCHAIN_URL" - # Verified BEFORE anything is unpacked: a tarball that fails this - # check must not have written a single file, least of all one on the - # PATH the compiler is about to use. - echo "${MUSL_TOOLCHAIN_SHA256} musl-native.tgz" | sha256sum -c - || { - echo "::error::musl toolchain digest mismatch — got $(sha256sum musl-native.tgz | cut -d' ' -f1)" - exit 1; } - # Extracted once, into /opt, which is the copy the PATH below names. - # Upstream also unpacked a second copy into the working directory - # and never used it. - sudo tar zxf musl-native.tgz -C /opt/ - export PATH="/opt/x86_64-linux-musl-native/bin/:${PATH}" - # Keeps the binary dynamically linked against musl, which is what - # makes the libc check in ffi-preflight.yml meaningful. - export RUSTFLAGS="-C target-feature=-crt-static" - pnpm run "$BUILD_SCRIPT" - elif [ "$BUILD_SCRIPT" = zigbuild ]; then + if [ "$BUILD_SCRIPT" = zigbuild ]; then + # `aarch64-unknown-linux-gnu` -> `aarch64-linux-gnu-gcc`. Parameter + # expansion rather than upstream's `sed`, and assigned before + # export rather than through it: actionlint runs shellcheck over + # `run:` blocks and the original spelling draws SC2001 and SC2155. + linker="${CARGO_BUILD_TARGET/unknown-/}-gcc" export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$linker" # cargo-zigbuild >= 0.23.0 no longer reads CARGO_BUILD_TARGET from # the environment, so the glibc-pinned target is passed as a flag. @@ -246,7 +210,58 @@ jobs: pnpm run "$BUILD_SCRIPT" fi + # The musl binary is built inside Alpine Linux, a musl system, so the + # compiler and its runtime libraries are musl's own. The toolchain this + # leg used to download from musl.cc timed out from GitHub's runners on + # six tries on 2 October 2026, and its digest pinned the file without + # authenticating its source. The image is pinned by digest because its + # output is published with provenance, and it is the image that + # _build-auth-artifacts.yml builds `@cipherstash/auth` in. + # + # `-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. Rust is the runner image's version, as on the other five legs. + # The build script, log and placement are the host legs' own. + - name: Build the binding in Alpine (linux-x64-musl) + if: ${{ matrix.cfg.platform == 'linux-x64-musl' }} + env: + CARGO_BUILD_TARGET: ${{ matrix.cfg.target }} + PLATFORM: ${{ matrix.cfg.platform }} + BUILD_SCRIPT: ${{ matrix.cfg.script }} + BUILD_LOG: ${{ matrix.cfg.log }} + ALPINE_NODE_IMAGE: node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 + run: | + set -euo pipefail + rust_version=$(rustc --version | cut -d' ' -f2) + pnpm_spec=$(node -p "require('./package.json').packageManager") + docker run --rm \ + -v "$GITHUB_WORKSPACE:/build" -w /build \ + -e CARGO_BUILD_TARGET -e PLATFORM -e BUILD_SCRIPT -e BUILD_LOG \ + -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 "$CARGO_BUILD_TARGET" + . "$HOME/.cargo/env" + corepack enable + corepack prepare "$PNPM_SPEC" --activate + pnpm install --frozen-lockfile --ignore-scripts --filter "@cipherstash/protect-ffi..." + cd languages/typescript/packages/protect-ffi + NEON_BUILD_PLATFORM="$PLATFORM" pnpm run "$BUILD_SCRIPT" + # The same placement as the host legs, here because the cargo + # log names the artifact by its path inside this container. + pnpm exec neon dist -n protect-ffi -o "platforms/$PLATFORM/index.node" < "$BUILD_LOG" + test -s "platforms/$PLATFORM/index.node" + # 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/platforms/$PLATFORM/index.node" + ' + - name: Place the binding in its platform package + if: ${{ matrix.cfg.platform != 'linux-x64-musl' }} working-directory: languages/typescript/packages/protect-ffi env: PLATFORM: ${{ matrix.cfg.platform }} diff --git a/.github/workflows/ffi-preflight.yml b/.github/workflows/ffi-preflight.yml index ac5379683..7cf99328d 100644 --- a/.github/workflows/ffi-preflight.yml +++ b/.github/workflows/ffi-preflight.yml @@ -3,8 +3,8 @@ name: FFI release pre-flight # `changeset publish` has no --dry-run, so this IS the dry run: build the real # artifacts, check every binary is the architecture and libc its package name # claims, then install the host-matching pair into a scratch project and use -# them. Point it at the Version Packages PR branch so the tarballs tested carry -# the exact versions that will publish. +# them, and the musl pair inside Alpine. Point it at the Version Packages PR +# branch so the tarballs tested carry the exact versions that will publish. # # It never publishes, and cannot — both authentication paths are absent, which # takes more than the one line it looks like. No `id-token` permission closes @@ -150,3 +150,26 @@ jobs: EOF node smoke.mjs + # The host steps above install 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"/ffi-dist/cipherstash-protect-ffi-[0-9]*.tgz)") + musl=$(basename "$(ls "$GITHUB_WORKSPACE"/ffi-dist/cipherstash-protect-ffi-linux-x64-musl-*.tgz)") + docker run --rm -v "$GITHUB_WORKSPACE/ffi-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 ffi = require(\"@cipherstash/protect-ffi\") + ffi.assertNativeBindingAvailable() + if (typeof ffi.isEncrypted !== \"function\") throw new Error(\"no isEncrypted\") + console.log(\"musl smoke OK\") + " + ' diff --git a/scripts/__tests__/ffi-build-artifacts.test.mjs b/scripts/__tests__/ffi-build-artifacts.test.mjs index 0c44f6e76..f2cb8e114 100644 --- a/scripts/__tests__/ffi-build-artifacts.test.mjs +++ b/scripts/__tests__/ffi-build-artifacts.test.mjs @@ -1,12 +1,18 @@ import { describe, expect, it } from 'vitest' -import { readWorkflow } from './lib/workflows.mjs' +import { readWorkflow, workflowFiles } from './lib/workflows.mjs' /** * `_build-ffi-artifacts.yml` builds the seven `@cipherstash/protect-ffi` - * tarballs that `release.yml`'s `publish-ffi` uploads. Every Linux binary's C - * library is checked before it is packed. Without the check, only a hand-run - * ffi-preflight sees a glibc-linked musl binary, which fails to load on musl - * systems such as Alpine Linux, and a release would publish it. + * tarballs that `release.yml`'s `publish-ffi` uploads. Two things about its + * Linux legs ship a broken package, rather than failing a build, if an edit + * undoes them: + * + * 1. Every Linux binary's C library is checked before it is packed. Without + * the check, only a hand-run ffi-preflight sees a glibc-linked musl binary, + * which fails to load on musl systems such as Alpine Linux. + * 2. The musl binary is built inside Alpine Linux, from an image pinned by + * digest, and loaded there. Its toolchain used to come from musl.cc, which + * timed out from GitHub's runners on six tries on 2 October 2026. */ const workflow = readWorkflow('.github/workflows/_build-ffi-artifacts.yml') @@ -17,15 +23,25 @@ const named = (name) => steps.findIndex((step) => step?.name === name) /** A GitHub Actions expression, as the parsed workflow holds it. */ const gha = (expression) => `\${{ ${expression} }}` +const MUSL = "matrix.cfg.platform == 'linux-x64-musl'" +const NOT_MUSL = "matrix.cfg.platform != 'linux-x64-musl'" +const PINNED = /^node:\d+-alpine@sha256:[0-9a-f]{64}$/ + describe('_build-ffi-artifacts.yml', () => { it('checks the C library of every Linux binary before it is packed', () => { const check = steps.findIndex((step) => runOf(step).includes('scripts/check-c-library.sh'), ) - const place = named('Place the binding in its platform package') const pack = named('Pack the platform package') - expect(place).toBeGreaterThan(-1) - expect(check).toBeGreaterThan(place) + expect(pack).toBeGreaterThan(-1) + // After both ways a binary is placed in its platform package. + for (const place of [ + named('Place the binding in its platform package'), + named('Build the binding in Alpine (linux-x64-musl)'), + ]) { + expect(place).toBeGreaterThan(-1) + expect(check).toBeGreaterThan(place) + } expect(check).toBeLessThan(pack) expect(String(steps[check].if)).toContain("runner.os == 'Linux'") // The binary that is packed, under the leg's own platform name. @@ -34,4 +50,111 @@ describe('_build-ffi-artifacts.yml', () => { /check-c-library\.sh "\$PLATFORM" "languages\/typescript\/packages\/protect-ffi\/platforms\/\$\{PLATFORM\}\/index\.node"/, ) }) + + it('builds the musl binding inside Alpine, from an image pinned by digest', () => { + const musl = steps.filter((step) => String(step?.if ?? '').includes(MUSL)) + expect(musl).toHaveLength(1) + const [build] = musl + const run = runOf(build) + expect(build.env?.ALPINE_NODE_IMAGE).toMatch(PINNED) + // 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 hands its files back even when the build fails. + expect(run).toMatch(/^\s*trap "chown -R .*\/build" EXIT$/m) + // The matrix decides the target, build script and log, as on the other + // legs, and the container is given all of them. + expect(build.env).toMatchObject({ + CARGO_BUILD_TARGET: gha('matrix.cfg.target'), + PLATFORM: gha('matrix.cfg.platform'), + BUILD_SCRIPT: gha('matrix.cfg.script'), + BUILD_LOG: gha('matrix.cfg.log'), + }) + for (const name of [ + 'CARGO_BUILD_TARGET', + 'PLATFORM', + 'BUILD_SCRIPT', + 'BUILD_LOG', + ]) { + expect(run).toMatch(new RegExp(`-e ${name}\\b`)) + } + // Build, place, then load the placed binary, on musl. + expect(run).toMatch( + /pnpm run "\$BUILD_SCRIPT"[\s\S]*neon dist -n protect-ffi -o "platforms\/\$PLATFORM\/index\.node" < "\$BUILD_LOG"[\s\S]*node -e "require\(process\.argv\[1\]\)" "\$PWD\/platforms\/\$PLATFORM\/index\.node"/, + ) + }) + + it('runs no host build step on the musl leg', () => { + for (const name of [ + 'Add the Rust target', + 'Install node-gyp', + 'Install dependencies', + 'Build binding', + 'Place the binding in its platform package', + ]) { + const step = steps[named(name)] + expect(step, name).toBeDefined() + expect(String(step?.if ?? ''), name).toContain(NOT_MUSL) + } + }) + + it('downloads no toolchain from musl.cc', () => { + // The parsed workflow, so the comment that records why is not counted. + expect(JSON.stringify(workflow)).not.toMatch(/musl\.cc/) + }) +}) + +describe('ffi-preflight.yml', () => { + const preflight = readWorkflow('.github/workflows/ffi-preflight.yml') + const smoke = preflight?.jobs?.smoke?.steps ?? [] + + it('checks the C library of every Linux binary with the shared script', () => { + const verify = smoke.map(runOf).join('\n') + expect(verify).toMatch( + /if \[\[ "\$platform" == linux-\* \]\]; then\s+"\$GITHUB_WORKSPACE\/scripts\/check-c-library\.sh" "\$platform" x\/package\/index\.node/, + ) + // The script comes from the commit that was built, and the checkout comes + // first, because a checkout empties the directory it checks out into. + const checkout = smoke.findIndex((step) => + String(step?.uses ?? '').startsWith('actions/checkout@'), + ) + const download = smoke.findIndex((step) => + String(step?.uses ?? '').startsWith('actions/download-artifact@'), + ) + expect(checkout).toBeGreaterThan(-1) + expect(checkout).toBeLessThan(download) + expect(smoke[checkout].with?.ref).toBe(gha('inputs.ref')) + }) + + it('loads the musl artifact inside Alpine', () => { + // The host smoke test installs only the runner's own platform, so without + // this step no job loads the musl package that was packed. + const alpine = smoke.find((step) => /\bdocker run\b/.test(runOf(step))) + expect(alpine).toBeDefined() + const run = runOf(alpine) + expect(run).toMatch(/docker run[\s\S]*"\$ALPINE_NODE_IMAGE"/) + expect(run).toContain('cipherstash-protect-ffi-linux-x64-musl-') + expect(run).toMatch( + /npm install [^\n]*"\/dist\/\$WRAPPER" "\/dist\/\$MUSL"/, + ) + // A bare require loads nothing: the binding resolves on first use. + expect(run).toContain('ffi.assertNativeBindingAvailable()') + }) +}) + +describe('the Alpine image', () => { + it('is one pinned image in every workflow, so a load test matches its build', () => { + const images = workflowFiles().flatMap((file) => + Object.values(readWorkflow(file)?.jobs ?? {}).flatMap((job) => + (job?.steps ?? []) + .map((step) => step?.env?.ALPINE_NODE_IMAGE) + .filter(Boolean), + ), + ) + // Both builds and both preflights. + expect(images.length).toBeGreaterThanOrEqual(4) + expect(new Set(images).size).toBe(1) + expect(images[0]).toMatch(PINNED) + }) }) From c4629eed0ba2cbc3f197b657f2a4d56c244a3096 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 09:37:36 +1000 Subject: [PATCH 4/5] test: check the C library rules on dynamic sections that outlast a pipe The script reads readelf's output into a variable because a pipe into `grep -q` fails open: grep exits at its match, readelf takes SIGPIPE, and under pipefail the match reads as a miss. The short fixtures did not catch a regression to the pipe, because readelf finished writing before grep exited. Two fixtures now name the C library first and then 3000 other libraries, so readelf is still writing when grep exits. With the pipe, the gnu and musl platforms reject them; with the variable, they accept. Refs: CIP-4284 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- scripts/__tests__/check-c-library.test.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/check-c-library.test.mjs b/scripts/__tests__/check-c-library.test.mjs index 647f919b6..dc137703a 100644 --- a/scripts/__tests__/check-c-library.test.mjs +++ b/scripts/__tests__/check-c-library.test.mjs @@ -166,10 +166,20 @@ function elf({ needed }) { const dir = mkdtempSync(join(tmpdir(), 'check-c-library-')) afterAll(() => rmSync(dir, { recursive: true, force: true })) +// Enough NEEDED entries that readelf is still writing after the C library's +// line. A `grep -q` reading readelf through a pipe exits at that line, readelf +// takes SIGPIPE, and under pipefail the match reads as a miss. +const FILLER = Array.from( + { length: 3000 }, + (_, i) => `libfiller-${String(i).padStart(4, '0')}.so`, +) + /** The libraries a Rust cdylib names on each C library, as readelf lists them. */ const FIXTURES = { gnu: ['libgcc_s.so.1', 'libm.so.6', 'libc.so.6', 'ld-linux-x86-64.so.2'], musl: ['libgcc_s.so.1', 'libc.musl-x86_64.so.1'], + 'gnu-long': ['libc.so.6', ...FILLER], + 'musl-long': ['libc.musl-x86_64.so.1', ...FILLER], // A binary with a dynamic section that names no C library: a C library // linked in statically, with only libgcc left dynamic. 'no-libc': ['libgcc_s.so.1'], @@ -213,16 +223,16 @@ describe.skipIf(!hasReadelf && !process.env.CI)('check-c-library.sh', () => { }) const ACCEPTS = { - 'linux-x64-gnu': 'gnu', - 'linux-arm64-gnu': 'gnu', - 'linux-x64-musl': 'musl', + 'linux-x64-gnu': ['gnu', 'gnu-long'], + 'linux-arm64-gnu': ['gnu', 'gnu-long'], + 'linux-x64-musl': ['musl', 'musl-long'], } const cases = Object.entries(ACCEPTS).flatMap(([platform, accepted]) => Object.keys(FIXTURES).map((binary) => ({ platform, binary, - ok: binary === accepted, + ok: accepted.includes(binary), })), ) From b816cf92337e01e107b77fc94370037abf1d4233 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 09:40:42 +1000 Subject: [PATCH 5/5] ci(ffi): pin mise in the protect-ffi release build Both mise steps in _build-ffi-artifacts.yml ran without a mise version, so the action installed the newest mise. mise 2026.10.0, released on 2 October 2026, refuses to install a cargo: tool until the Rust that mise is configured with is installed: tool 'cargo:cargo-zigbuild@0.23.0' requires configured install dependency 'rust@1.94.1', but its selected version is not installed The root mise.toml pins rust 1.94.1, and the zig step installs only `zig cargo:cargo-zigbuild`, so that Rust is never installed there. Main fails this way without this branch. ffi-preflight run 37079039403, dispatched on main at cb58a7b9, failed both gnu legs at that step (jobs 111075318561 and 111075318565), with mise 2026.10.0. Main's run 36969039169 passed with the same mise at 05:27Z, because it built 966be055, which has no root mise.toml. The stack crates import added it later that day. integration-protect-ffi.yml still passes on main because its bare `mise install` installs rust 1.94.1 first. Both steps now pin mise 2026.4.0, the version that _build-auth-artifacts.yml and the EQL release builds pin. With the pin, ffi-preflight run 37078751162 passed every leg. A test checks that every mise step in the file carries the auth build's version. Refs: CIP-4282 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/_build-ffi-artifacts.yml | 7 +++++++ .../__tests__/ffi-build-artifacts.test.mjs | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/_build-ffi-artifacts.yml b/.github/workflows/_build-ffi-artifacts.yml index 9f20b55fa..d0d4d4410 100644 --- a/.github/workflows/_build-ffi-artifacts.yml +++ b/.github/workflows/_build-ffi-artifacts.yml @@ -171,10 +171,16 @@ jobs: # from the triple: scripts/ffi-release-matrix.mjs picks zigbuild, and this # step exists to supply what zigbuild needs. Two spellings of one rule # drift apart the day a platform moves between them. + # + # mise itself is pinned, at the version the auth and EQL release builds + # use. Unpinned, the action installs the latest mise, and + # mise 2026.10.0 (2 October 2026) refuses to install cargo-zigbuild until + # the root mise.toml's Rust is installed, which failed both gnu legs. - name: Install zig + cargo-zigbuild (zigbuild platforms only) if: ${{ matrix.cfg.script == 'zigbuild' }} uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 with: + version: 2026.4.0 install: true install_args: zig cargo:cargo-zigbuild working_directory: languages/typescript/packages/protect-ffi @@ -382,6 +388,7 @@ jobs: - name: Install wasm-pack uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 with: + version: 2026.4.0 install: true install_args: aqua:wasm-bindgen/wasm-pack working_directory: languages/typescript/packages/protect-ffi diff --git a/scripts/__tests__/ffi-build-artifacts.test.mjs b/scripts/__tests__/ffi-build-artifacts.test.mjs index f2cb8e114..b2f3d8b69 100644 --- a/scripts/__tests__/ffi-build-artifacts.test.mjs +++ b/scripts/__tests__/ffi-build-artifacts.test.mjs @@ -103,6 +103,26 @@ describe('_build-ffi-artifacts.yml', () => { // The parsed workflow, so the comment that records why is not counted. expect(JSON.stringify(workflow)).not.toMatch(/musl\.cc/) }) + + it('pins mise in every mise step, at the auth release build version', () => { + // Unpinned, the action installs the newest mise. mise 2026.10.0 refused + // to install cargo-zigbuild, and both gnu legs failed. + const miseSteps = (wf) => + Object.values(wf?.jobs ?? {}).flatMap((job) => + (job?.steps ?? []).filter((step) => + String(step?.uses ?? '').startsWith('jdx/mise-action@'), + ), + ) + const [auth] = miseSteps( + readWorkflow('.github/workflows/_build-auth-artifacts.yml'), + ) + expect(auth?.with?.version).toMatch(/^\d{4}\.\d+\.\d+$/) + const ffi = miseSteps(workflow) + expect(ffi.length).toBeGreaterThanOrEqual(2) + for (const step of ffi) { + expect(step.with?.version, step.name).toBe(auth.with.version) + } + }) }) describe('ffi-preflight.yml', () => {