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/_build-ffi-artifacts.yml b/.github/workflows/_build-ffi-artifacts.yml index b11178ba4..d0d4d4410 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 @@ -169,75 +171,43 @@ 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 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 +216,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 }} @@ -269,6 +290,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 @@ -357,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/.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..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 @@ -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,27 @@ jobs: console.log('smoke OK') 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/.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..dc137703a --- /dev/null +++ b/scripts/__tests__/check-c-library.test.mjs @@ -0,0 +1,341 @@ +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 })) + +// 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'], + // 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', '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: accepted.includes(binary), + })), + ) + + 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/_build-ffi-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/__tests__/ffi-build-artifacts.test.mjs b/scripts/__tests__/ffi-build-artifacts.test.mjs new file mode 100644 index 000000000..b2f3d8b69 --- /dev/null +++ b/scripts/__tests__/ffi-build-artifacts.test.mjs @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +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. 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') +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} }}` + +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 pack = named('Pack the platform package') + 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. + 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"/, + ) + }) + + 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/) + }) + + 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', () => { + 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) + }) +}) 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"