From 5572c9434a7d1116b33eba2ddd77e8950b0cc3a1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 14 Aug 2026 05:33:01 +0700 Subject: [PATCH] fix(release): build Linux Node clients with Zig Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 45 ++++ .github/scripts/test_ci_changes.py | 61 +++++ .github/workflows/ci.yml | 77 ++++++ .github/workflows/release-candidate.yml | 49 +--- .github/workflows/release-rehearsal.yml | 114 +++++++++ release/scripts/build-linux-node-client | 135 +++++++++++ release/scripts/check-gates-inventory.py | 28 +++ .../scripts/test_build_linux_node_client.py | 220 ++++++++++++++++++ release/scripts/test_check_gates_inventory.py | 108 +++++++++ release/scripts/test_release_rehearsal.py | 81 ++++++- .../test_release_workflow_structure.py | 89 ++++--- release/scripts/test_zig_glibc_compiler.py | 126 ++++++++++ release/scripts/zig-glibc-compiler | 59 +++++ 13 files changed, 1124 insertions(+), 68 deletions(-) create mode 100755 release/scripts/build-linux-node-client create mode 100644 release/scripts/test_build_linux_node_client.py create mode 100644 release/scripts/test_zig_glibc_compiler.py create mode 100755 release/scripts/zig-glibc-compiler diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 90d17a827..4d44ff8e4 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -153,6 +153,34 @@ {"registry-relay-client-node", "registry-relay-client-py"} ) NATIVE_BINDING_PACKAGES = EVIDENCE_BINDING_PACKAGES | RELAY_BINDING_PACKAGES +LINUX_NODE_BINDING_PACKAGES = frozenset( + {"registry-evidence-client-node", "registry-relay-client-node"} +) + +# Inputs that can change the production Linux Node client recipe without +# changing either binding crate. This proof is deliberately selected from the +# actual changed paths rather than `complete`: push and merge-queue CI use +# `--all` for their Rust matrices, and an unrelated change must not rebuild two +# release addons merely because those matrices are complete. +LINUX_NODE_RELEASE_RECIPE_INPUTS = frozenset( + { + ".github/scripts/ci_changes.py", + ".github/workflows/ci.yml", + ".github/workflows/release-candidate.yml", + ".github/workflows/release-rehearsal.yml", + "Cargo.lock", + "Cargo.toml", + "release/requirements/maturin-1.9.6.txt", + "release/scripts/build-linux-node-client", + "release/scripts/smoke-evidence-client-package.js", + "release/scripts/smoke-relay-client-package.js", + "release/scripts/test_build_linux_node_client.py", + "release/scripts/test_zig_glibc_compiler.py", + "release/scripts/zig-glibc-compiler", + "rust-toolchain", + "rust-toolchain.toml", + } +) # A package is exempt from the tutorial trigger only while no tutorial runs it. # The Python binding is what `request-evidence-from-an-application` imports, so @@ -433,6 +461,22 @@ def classify( ) complete = run_all or force_all + # Compute this closure independently of the broad Rust selection above. + # In particular, `run_all=True` must not manufacture a release recipe + # trigger when no relevant path changed. + linux_node_seeds = { + package + for path in paths + if (package := workspace.package_for_path(path)) is not None + } + release_linux_node_clients = any( + path in LINUX_NODE_RELEASE_RECIPE_INPUTS or path.startswith(".cargo/") + for path in paths + ) or bool( + workspace.affected_packages(linux_node_seeds) + & LINUX_NODE_BINDING_PACKAGES + ) + identifiers = complete or any( matches(path, *IDENTIFIER_CATALOG_INPUTS) for path in paths ) @@ -597,6 +641,7 @@ def classify( "docs_archives": docs_archives, "editors": editors, "client_bindings": client_bindings, + "release_linux_node_clients": release_linux_node_clients, "evidence_tutorial": evidence_tutorial, "identifiers": identifiers, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 75a3de30c..6171cdb7c 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -652,6 +652,67 @@ def test_an_sdk_or_verifier_change_also_runs_the_binding_job(self) -> None: ) self.assertIn("registry-evidence-client-py", outputs["rust_packages"]) + def test_linux_node_release_recipe_proof_follows_binding_dependency_closure( + self, + ) -> None: + for path in ( + "crates/registry-evidence-client-node/src/lib.rs", + "crates/registry-relay-client-node/src/lib.rs", + "crates/registry-evidence-client/src/client.rs", + "crates/registry-relay-client/src/client.rs", + "crates/registry-platform-httputil/src/lib.rs", + ): + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["release_linux_node_clients"] + ) + + def test_linux_node_release_recipe_inputs_select_the_proof(self) -> None: + for path in ( + "Cargo.lock", + "Cargo.toml", + ".cargo/config.toml", + "rust-toolchain", + "rust-toolchain.toml", + "release/requirements/maturin-1.9.6.txt", + "release/scripts/build-linux-node-client", + "release/scripts/smoke-evidence-client-package.js", + "release/scripts/smoke-relay-client-package.js", + "release/scripts/test_build_linux_node_client.py", + "release/scripts/test_zig_glibc_compiler.py", + "release/scripts/zig-glibc-compiler", + ".github/scripts/ci_changes.py", + ".github/workflows/ci.yml", + ".github/workflows/release-candidate.yml", + ".github/workflows/release-rehearsal.yml", + ): + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["release_linux_node_clients"] + ) + + def test_complete_matrix_alone_does_not_select_linux_node_release_recipe( + self, + ) -> None: + for paths in ( + (), + ("release/notes/v0.22.0.md",), + ("docs/site/src/content/docs/reference/glossary.mdx",), + (".github/workflows/unrelated.yml",), + ): + with self.subTest(paths=paths): + outputs = classify(self.workspace, paths, run_all=True) + self.assertTrue(outputs["rust"]) + self.assertFalse(outputs["release_linux_node_clients"]) + + def test_run_all_preserves_a_real_linux_node_recipe_trigger(self) -> None: + outputs = classify( + self.workspace, + ("crates/registry-evidence-client/src/client.rs",), + run_all=True, + ) + self.assertTrue(outputs["release_linux_node_clients"]) + def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn("\n evidence-contracts:\n", workflow) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52384ec08..1c2e97d6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,7 @@ jobs: docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} client_bindings: ${{ steps.filter.outputs.client_bindings }} + release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }} evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} identifiers: ${{ steps.filter.outputs.identifiers }} steps: @@ -646,6 +647,12 @@ jobs: - name: Test release rehearsal workflow run: python3 -m unittest release/scripts/test_release_rehearsal.py + - name: Test Linux Node client release build helper + run: python3 -m unittest release/scripts/test_build_linux_node_client.py + + - name: Test Zig glibc compiler wrapper + run: python3 -m unittest release/scripts/test_zig_glibc_compiler.py + - name: Test release workflow structure run: python3 -m unittest release/scripts/test_release_workflow_structure.py @@ -1057,6 +1064,75 @@ jobs: ) done + release-linux-node-clients: + name: Release Linux Node clients (${{ matrix.asset }}) + needs: changes + if: needs.changes.outputs.release_linux_node_clients == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + permissions: + contents: read + env: + RUSTUP_TOOLCHAIN: "1.95.0" + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + asset: linux-amd64-glibc + target: x86_64-unknown-linux-gnu + napi_platform: linux-x64-gnu + - runner: ubuntu-24.04-arm + asset: linux-arm64-glibc + target: aarch64-unknown-linux-gnu + napi_platform: linux-arm64-gnu + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + submodules: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.20.0 + cache: npm + cache-dependency-path: | + crates/registry-evidence-client-node/package-lock.json + crates/registry-relay-client-node/package-lock.json + + - name: Install pinned Linux client build tools + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.95.0 --profile minimal + python3 -m venv "${RUNNER_TEMP}/maturin" + "${RUNNER_TEMP}/maturin/bin/pip" install --quiet \ + --require-hashes --only-binary=:all: \ + --requirement "${GITHUB_WORKSPACE}/release/requirements/maturin-1.9.6.txt" + + - name: Prove production Linux Node client recipe + shell: bash + run: | + set -euo pipefail + for client in evidence relay; do + client_dir="${GITHUB_WORKSPACE}/crates/registry-${client}-client-node" + (cd "${client_dir}" && npm ci) + release/scripts/build-linux-node-client \ + --client "${client}" \ + --target "${{ matrix.target }}" \ + --napi-platform "${{ matrix.napi_platform }}" \ + --zig-python "${RUNNER_TEMP}/maturin/bin/python" + smoke="${RUNNER_TEMP}/node-smoke-${client}" + mkdir -p "${smoke}/node_modules/@registrystack" + ln -s "${client_dir}" \ + "${smoke}/node_modules/@registrystack/${client}-client" + cp "${GITHUB_WORKSPACE}/release/scripts/smoke-${client}-client-package.js" \ + "${smoke}/" + (cd "${smoke}" && node "smoke-${client}-client-package.js") + done + ci-result: name: CI result if: always() @@ -1075,6 +1151,7 @@ jobs: - docs - editor-extensions - client-bindings + - release-linux-node-clients runs-on: ubuntu-24.04 env: CI_JOB_RESULTS: ${{ toJSON(needs) }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index de3ee950b..237d7dc6d 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -475,9 +475,9 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: registry-stack-release-clients-napi-cross-glibc-2.17-${{ matrix.asset }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'crates/registry-evidence-client-node/package-lock.json', 'crates/registry-relay-client-node/package-lock.json') }} + key: registry-stack-release-clients-zig-0.12.1-glibc-2.17-${{ matrix.asset }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'release/requirements/maturin-1.9.6.txt', 'release/scripts/zig-glibc-compiler', 'release/scripts/build-linux-node-client', 'crates/registry-evidence-client-node/package-lock.json', 'crates/registry-relay-client-node/package-lock.json') }} restore-keys: | - registry-stack-release-clients-napi-cross-glibc-2.17-${{ matrix.asset }}- + registry-stack-release-clients-zig-0.12.1-glibc-2.17-${{ matrix.asset }}- - name: Build Python client wheels shell: bash @@ -572,44 +572,15 @@ jobs: client_dir="${GITHUB_WORKSPACE}/crates/registry-${client}-client-node" (cd "${client_dir}" && npm ci) test "$(node -p "require('${client_dir}/package.json').version")" = "${CLIENT_VERSION}" - napi_args=(--platform --release --target "${{ matrix.target }}") if [[ "${RUNNER_OS}" == Linux ]]; then - # The published linux-*-gnu package names carry no distro floor. - # aws-lc-sys treats a same-architecture target as a native build, - # so route its host C and C++ compilers through napi-rs' locked - # glibc 2.17 toolchain too. Otherwise it inherits the Ubuntu 24.04 - # headers even though Rust links with the older toolchain. - export HOST_CC="${{ matrix.target }}-gcc" - export HOST_CXX="${{ matrix.target }}-g++" - napi_args+=(--use-napi-cross) - fi - (cd "${client_dir}" && ./node_modules/.bin/napi build "${napi_args[@]}") - addon="${client_dir}/${client}-client.${{ matrix.napi_platform }}.node" - if [[ "${RUNNER_OS}" == Linux ]]; then - # The version table misses unversioned imports. Candidate - # packaging rejects strong ones except the Node-API imports that - # the host intentionally resolves; weak imports remain optional. - # Rebuild with the pinned compilers is recovery, until the build - # host itself enforces the ABI floor. - unversioned_imports="$( - readelf --wide --dyn-syms "${addon}" \ - | awk '$7 == "UND" && $5 != "WEAK" && $8 !~ /@/ && $8 !~ /^(napi_|node_api_)/ { print $8 }' \ - | sort -u - )" - if [[ -n "${unversioned_imports}" ]]; then - printf 'native addon has strong unversioned imports:\n%s\n' \ - "${unversioned_imports}" >&2 - exit 1 - fi - highest_glibc="$( - readelf --version-info "${addon}" \ - | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \ - | sort -Vu \ - | tail -1 - )" - test -n "${highest_glibc}" - test "$(printf '%s\n' GLIBC_2.17 "${highest_glibc}" | sort -V | tail -1)" = \ - GLIBC_2.17 + release/scripts/build-linux-node-client \ + --client "${client}" \ + --target "${{ matrix.target }}" \ + --napi-platform "${{ matrix.napi_platform }}" \ + --zig-python "${RUNNER_TEMP}/maturin/bin/python" + else + (cd "${client_dir}" && ./node_modules/.bin/napi build \ + --platform --release --target "${{ matrix.target }}") fi (cd "${client_dir}" && npm pack --pack-destination "${RUNNER_TEMP}") packed="${RUNNER_TEMP}/registrystack-${client}-client-${CLIENT_VERSION}.tgz" diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index cccb7f841..905447800 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -67,3 +67,117 @@ jobs: --version "${REHEARSAL_VERSION}" --release-id "${REHEARSAL_RELEASE_ID}" --base-ref origin/main + + node-clients: + name: Prove Linux Node clients for ${{ matrix.asset }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + env: + CLIENT_VERSION: ${{ inputs.version }} + RUSTUP_TOOLCHAIN: "1.95.0" + NODE_GLIBC_BASELINE_IMAGE: node:22.12.0-bullseye-slim@sha256:52e4282a01d63eb4cfce7a395364d366cee488c278079110d3aa49dd21b2bf18 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + asset: linux-amd64-glibc + target: x86_64-unknown-linux-gnu + napi_platform: linux-x64-gnu + - runner: ubuntu-24.04-arm + asset: linux-arm64-glibc + target: aarch64-unknown-linux-gnu + napi_platform: linux-arm64-gnu + steps: + - name: Require a branch rehearsal + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF}" != refs/heads/* ]]; then + echo "release rehearsal must run from a prepared branch" >&2 + exit 1 + fi + + - name: Checkout prepared branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.20.0 + cache: npm + cache-dependency-path: | + crates/registry-evidence-client-node/package-lock.json + crates/registry-relay-client-node/package-lock.json + + - name: Install exact Zig toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.95.0 --profile minimal + python3 -m venv "${RUNNER_TEMP}/maturin" + "${RUNNER_TEMP}/maturin/bin/pip" install --quiet \ + --require-hashes --only-binary=:all: \ + --requirement "${GITHUB_WORKSPACE}/release/requirements/maturin-1.9.6.txt" + test "$("${RUNNER_TEMP}/maturin/bin/python" -m ziglang version)" = 0.12.1 + + - name: Build, package, and smoke Linux Node clients + shell: bash + run: | + set -euo pipefail + for client in evidence relay; do + client_dir="${GITHUB_WORKSPACE}/crates/registry-${client}-client-node" + (cd "${client_dir}" && npm ci) + test "$(node -p "require('${client_dir}/package.json').version")" = \ + "${CLIENT_VERSION}" + release/scripts/build-linux-node-client \ + --client "${client}" \ + --target "${{ matrix.target }}" \ + --napi-platform "${{ matrix.napi_platform }}" \ + --zig-python "${RUNNER_TEMP}/maturin/bin/python" + + platform_dir="${client_dir}/npm/${{ matrix.napi_platform }}" + test -f "${platform_dir}/package.json" + cp "${client_dir}/LICENSE" "${platform_dir}/LICENSE" + cp "${client_dir}/${client}-client.${{ matrix.napi_platform }}.node" \ + "${platform_dir}/" + (cd "${client_dir}" && npm pack \ + "./npm/${{ matrix.napi_platform }}" \ + --ignore-scripts --pack-destination "${RUNNER_TEMP}") + platform_package="${RUNNER_TEMP}/registrystack-${client}-client-${{ matrix.napi_platform }}-${CLIENT_VERSION}.tgz" + test -f "${platform_package}" + + smoke="${RUNNER_TEMP}/node-rehearsal-${client}" + mkdir -p "${smoke}" + root_stage="${RUNNER_TEMP}/node-root-${client}" + mkdir -p "${root_stage}" + for source in \ + LICENSE README.md client.js client.d.ts index.js index.d.ts package.json; do + cp "${client_dir}/${source}" "${root_stage}/${source}" + done + (cd "${root_stage}" && npm pack \ + --ignore-scripts --pack-destination "${smoke}") + root_package="${smoke}/registrystack-${client}-client-${CLIENT_VERSION}.tgz" + test -f "${root_package}" + ( + cd "${smoke}" + npm init --yes >/dev/null + npm install --no-audit --no-fund --ignore-scripts \ + "${root_package}" "${platform_package}" + test -z "$(find "node_modules/@registrystack/${client}-client" \ + -maxdepth 1 -name '*.node' -print -quit)" + test -f \ + "node_modules/@registrystack/${client}-client-${{ matrix.napi_platform }}/${client}-client.${{ matrix.napi_platform }}.node" + cp "${GITHUB_WORKSPACE}/release/scripts/smoke-${client}-client-package.js" . + node "smoke-${client}-client-package.js" + docker run --rm --network none \ + --volume "${smoke}:${smoke}:ro" \ + --workdir "${smoke}" \ + "${NODE_GLIBC_BASELINE_IMAGE}" \ + node "smoke-${client}-client-package.js" + ) + done diff --git a/release/scripts/build-linux-node-client b/release/scripts/build-linux-node-client new file mode 100755 index 000000000..249755cb1 --- /dev/null +++ b/release/scripts/build-linux-node-client @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +client="" +rust_target="" +napi_platform="" +zig_python="" +while (( "$#" )); do + case "$1" in + --client|--target|--napi-platform|--zig-python) + if (( "$#" < 2 )); then + printf '%s requires a value\n' "$1" >&2 + exit 2 + fi + case "$1" in + --client) client="$2" ;; + --target) rust_target="$2" ;; + --napi-platform) napi_platform="$2" ;; + --zig-python) zig_python="$2" ;; + esac + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +case "${client}" in + evidence|relay) ;; + *) + printf 'client must be evidence or relay\n' >&2 + exit 2 + ;; +esac + +case "${rust_target}:${napi_platform}" in + x86_64-unknown-linux-gnu:linux-x64-gnu) + zig_target=x86_64-linux-gnu.2.17 + ;; + aarch64-unknown-linux-gnu:linux-arm64-gnu) + zig_target=aarch64-linux-gnu.2.17 + ;; + *) + printf 'unsupported Linux Node target/platform pair: %s/%s\n' \ + "${rust_target}" "${napi_platform}" >&2 + exit 2 + ;; +esac + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/../.." && pwd)" +client_dir="${repo_root}/crates/registry-${client}-client-node" +if [[ ! -f "${client_dir}/package.json" || + ! -x "${client_dir}/node_modules/.bin/napi" ]]; then + printf '%s npm dependencies must be installed before building\n' \ + "${client}" >&2 + exit 2 +fi +if [[ "${zig_python}" != /* || ! -x "${zig_python}" ]]; then + printf 'zig Python must name an absolute executable\n' >&2 + exit 2 +fi +zig_version="$("${zig_python}" -m ziglang version)" +if [[ "${zig_version}" != 0.12.1 ]]; then + printf 'zig Python must provide hash-pinned ziglang 0.12.1, got %s\n' \ + "${zig_version}" >&2 + exit 2 +fi + +compiler="${script_dir}/zig-glibc-compiler" +test -x "${compiler}" + +wrapper_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/registry-node-zig.XXXXXX")" +cleanup() { + rm -rf -- "${wrapper_root}" +} +trap cleanup EXIT +ln -s "${compiler}" "${wrapper_root}/zig-cc" +ln -s "${compiler}" "${wrapper_root}/zig-cxx" + +cc_wrapper="${wrapper_root}/zig-cc" +cxx_wrapper="${wrapper_root}/zig-cxx" +target_env="${rust_target//-/_}" +cargo_target_env="${target_env^^}" + +export REGISTRY_ZIG_PYTHON="${zig_python}" +export REGISTRY_ZIG_TARGET="${zig_target}" +export HOST_CC="${cc_wrapper}" +export HOST_CXX="${cxx_wrapper}" +export TARGET_CC="${cc_wrapper}" +export TARGET_CXX="${cxx_wrapper}" +export "CC_${target_env}=${cc_wrapper}" +export "CXX_${target_env}=${cxx_wrapper}" +export "CARGO_TARGET_${cargo_target_env}_LINKER=${cc_wrapper}" + +( + cd "${client_dir}" + ./node_modules/.bin/napi build \ + --platform --release --target "${rust_target}" +) + +addon="${client_dir}/${client}-client.${napi_platform}.node" +if [[ ! -f "${addon}" ]]; then + printf 'napi did not produce %s\n' "${addon}" >&2 + exit 1 +fi + +# The host intentionally resolves Node-API imports and weak imports remain +# optional. Every other strong undefined import must be version-bound. +unversioned_imports="$( + readelf --wide --dyn-syms "${addon}" \ + | awk '$7 == "UND" && $5 != "WEAK" && $8 !~ /@/ && $8 !~ /^(napi_|node_api_)/ { print $8 }' \ + | sort -u +)" +if [[ -n "${unversioned_imports}" ]]; then + printf 'native addon has strong unversioned imports:\n%s\n' \ + "${unversioned_imports}" >&2 + exit 1 +fi + +highest_glibc="$( + readelf --version-info "${addon}" \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \ + | sort -Vu \ + | tail -1 +)" +test -n "${highest_glibc}" +if [[ "$(printf '%s\n' GLIBC_2.17 "${highest_glibc}" | sort -V | tail -1)" != \ + GLIBC_2.17 ]]; then + printf 'native addon requires %s above the GLIBC_2.17 floor\n' \ + "${highest_glibc}" >&2 + exit 1 +fi diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index ddc7f594e..cf70a02ba 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -163,6 +163,26 @@ "Relay client source neutrality", "run: products/relay-v2/scripts/check-source-neutrality.sh", ), + ( + "Release Linux Node client path filter", + "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", + ), + ( + "Release Linux Node client proof job", + "release-linux-node-clients:\n name: Release Linux Node clients", + ), + ( + "Release Linux Node client helper invocation", + "release/scripts/build-linux-node-client \\", + ), + ( + "Release Linux Node client pinned tools", + '--requirement "${GITHUB_WORKSPACE}/release/requirements/maturin-1.9.6.txt"', + ), + ( + "Release Linux Node client CI aggregate", + " - release-linux-node-clients", + ), ( "Release helper tests", "run: python3 -m unittest release/scripts/test_registry_release.py", @@ -199,6 +219,14 @@ "Release rehearsal workflow tests", "run: python3 -m unittest release/scripts/test_release_rehearsal.py", ), + ( + "Linux Node client release build helper tests", + "run: python3 -m unittest release/scripts/test_build_linux_node_client.py", + ), + ( + "Zig glibc compiler wrapper tests", + "run: python3 -m unittest release/scripts/test_zig_glibc_compiler.py", + ), ( "Release workflow structure tests", "run: python3 -m unittest release/scripts/test_release_workflow_structure.py", diff --git a/release/scripts/test_build_linux_node_client.py b/release/scripts/test_build_linux_node_client.py new file mode 100644 index 000000000..a2451ab9f --- /dev/null +++ b/release/scripts/test_build_linux_node_client.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +BUILD = ROOT / "release/scripts/build-linux-node-client" +COMPILER = ROOT / "release/scripts/zig-glibc-compiler" + + +class BuildLinuxNodeClientTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + scripts = self.root / "release/scripts" + scripts.mkdir(parents=True) + for source in (BUILD, COMPILER): + destination = scripts / source.name + shutil.copy2(source, destination) + destination.chmod(0o755) + self.build = scripts / BUILD.name + + self.bin = self.root / "bin" + self.bin.mkdir() + self.zig_log = self.root / "zig.jsonl" + self.napi_log = self.root / "napi.json" + self.python = self.root / "maturin/bin/python" + self.python.parent.mkdir(parents=True) + self.python.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "if sys.argv[1:] == ['-m', 'ziglang', 'version']:\n" + " print(os.environ.get('ZIG_VERSION', '0.12.1'))\n" + " raise SystemExit\n" + "with open(os.environ['ZIG_LOG'], 'a', encoding='utf-8') as log:\n" + " log.write(json.dumps(sys.argv[1:]) + '\\n')\n", + encoding="utf-8", + ) + self.python.chmod(0o755) + + readelf = self.bin / "readelf" + readelf.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "if [[ \"$1\" == --wide ]]; then\n" + " case \"${READELF_MODE:-valid}\" in\n" + " strong) symbol='malloc' ;;\n" + " *) symbol='napi_create_function' ;;\n" + " esac\n" + " printf ' 1: 0 0 FUNC GLOBAL DEFAULT UND %s\\n' \"$symbol\"\n" + "else\n" + " printf 'Version needs section: %s\\n' \"${READELF_GLIBC:-GLIBC_2.17}\"\n" + "fi\n", + encoding="utf-8", + ) + readelf.chmod(0o755) + self.env = { + **os.environ, + "PATH": f"{self.bin}:{os.environ['PATH']}", + "RUNNER_TEMP": str(self.root / "runner"), + "ZIG_LOG": str(self.zig_log), + "NAPI_LOG": str(self.napi_log), + } + Path(self.env["RUNNER_TEMP"]).mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def make_client(self, client: str, target: str, platform: str) -> Path: + client_dir = self.root / f"crates/registry-{client}-client-node" + napi = client_dir / "node_modules/.bin/napi" + napi.parent.mkdir(parents=True) + (client_dir / "package.json").write_text("{}\n", encoding="utf-8") + napi.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, subprocess, sys\n" + "target = sys.argv[sys.argv.index('--target') + 1]\n" + "client = pathlib.Path.cwd().name.removeprefix('registry-').removesuffix('-node')\n" + f"platform = {platform!r}\n" + "selected = {key: value for key, value in os.environ.items() if " + "key in {'HOST_CC', 'HOST_CXX', 'TARGET_CC', 'TARGET_CXX'} or " + "key.startswith('CC_') or key.startswith('CXX_') or " + "key.startswith('CARGO_TARGET_')}\n" + "pathlib.Path(os.environ['NAPI_LOG']).write_text(json.dumps({" + "'args': sys.argv[1:], 'env': selected}), encoding='utf-8')\n" + "subprocess.run([os.environ['HOST_CC'], f'--target={target}', " + "'--target', target, '-target', target, '-O3', 'source.c'], check=True)\n" + "subprocess.run([os.environ['HOST_CXX'], '-std=c++17', 'source.cc'], check=True)\n" + "pathlib.Path(f'{client}.{platform}.node').touch()\n", + encoding="utf-8", + ) + napi.chmod(0o755) + return client_dir + + def run_build( + self, + client: str = "evidence", + target: str = "aarch64-unknown-linux-gnu", + platform: str = "linux-arm64-gnu", + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + str(self.build), + "--client", + client, + "--target", + target, + "--napi-platform", + platform, + "--zig-python", + str(self.python), + ], + cwd=self.root, + env=env or self.env, + capture_output=True, + text=True, + check=False, + ) + + def test_routes_every_compiler_and_linker_through_exact_zig_target(self) -> None: + self.make_client("evidence", "aarch64-unknown-linux-gnu", "linux-arm64-gnu") + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + napi = json.loads(self.napi_log.read_text()) + self.assertEqual( + napi["args"], + [ + "build", + "--platform", + "--release", + "--target", + "aarch64-unknown-linux-gnu", + ], + ) + self.assertNotIn("--use-napi-cross", napi["args"]) + env = napi["env"] + cc = env["HOST_CC"] + cxx = env["HOST_CXX"] + self.assertEqual(env["TARGET_CC"], cc) + self.assertEqual(env["TARGET_CXX"], cxx) + self.assertEqual(env["CC_aarch64_unknown_linux_gnu"], cc) + self.assertEqual(env["CXX_aarch64_unknown_linux_gnu"], cxx) + self.assertEqual( + env["CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER"], cc + ) + calls = [json.loads(line) for line in self.zig_log.read_text().splitlines()] + self.assertEqual( + calls[0], + [ + "-m", + "ziglang", + "cc", + "-target", + "aarch64-linux-gnu.2.17", + "-O3", + "source.c", + ], + ) + self.assertEqual( + calls[1][0:5], + ["-m", "ziglang", "c++", "-target", "aarch64-linux-gnu.2.17"], + ) + + def test_routes_x64_pair_through_exact_zig_target(self) -> None: + self.make_client("relay", "x86_64-unknown-linux-gnu", "linux-x64-gnu") + result = self.run_build( + client="relay", + target="x86_64-unknown-linux-gnu", + platform="linux-x64-gnu", + ) + self.assertEqual(result.returncode, 0, result.stderr) + napi = json.loads(self.napi_log.read_text()) + cc = napi["env"]["HOST_CC"] + self.assertEqual(napi["env"]["CC_x86_64_unknown_linux_gnu"], cc) + self.assertEqual( + napi["env"]["CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER"], cc + ) + calls = [json.loads(line) for line in self.zig_log.read_text().splitlines()] + self.assertEqual( + calls[0][0:5], + ["-m", "ziglang", "cc", "-target", "x86_64-linux-gnu.2.17"], + ) + + def test_rejects_unpinned_zig_version_before_build(self) -> None: + self.make_client("evidence", "aarch64-unknown-linux-gnu", "linux-arm64-gnu") + result = self.run_build(env={**self.env, "ZIG_VERSION": "0.13.0"}) + self.assertEqual(result.returncode, 2) + self.assertIn("hash-pinned ziglang 0.12.1", result.stderr) + self.assertFalse(self.napi_log.exists()) + + def test_rejects_mismatched_target_platform_before_build(self) -> None: + self.make_client("evidence", "aarch64-unknown-linux-gnu", "linux-x64-gnu") + result = self.run_build(platform="linux-x64-gnu") + self.assertEqual(result.returncode, 2) + self.assertIn("unsupported Linux Node target/platform pair", result.stderr) + self.assertFalse(self.napi_log.exists()) + + def test_rejects_strong_unversioned_import(self) -> None: + self.make_client("evidence", "aarch64-unknown-linux-gnu", "linux-arm64-gnu") + result = self.run_build(env={**self.env, "READELF_MODE": "strong"}) + self.assertEqual(result.returncode, 1) + self.assertIn("strong unversioned imports:\nmalloc", result.stderr) + + def test_rejects_glibc_above_floor(self) -> None: + self.make_client("evidence", "aarch64-unknown-linux-gnu", "linux-arm64-gnu") + result = self.run_build(env={**self.env, "READELF_GLIBC": "GLIBC_2.18"}) + self.assertEqual(result.returncode, 1) + self.assertIn("requires GLIBC_2.18 above the GLIBC_2.17 floor", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index e7e01a4c8..840d4d9aa 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -7,6 +7,8 @@ import unittest from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "release" / "scripts" / "check-gates-inventory.py" @@ -712,6 +714,104 @@ def test_missing_relay_client_contract_gates_are_reported(self) -> None: text = self.workflow.replace(snippet, replacement) self.assertIn(gate, self.module.missing_gates(text)) + def test_linux_node_release_proof_is_two_runner_read_only_and_aggregated( + self, + ) -> None: + document = yaml.safe_load(self.workflow) + job = document["jobs"]["release-linux-node-clients"] + self.assertEqual( + "needs.changes.outputs.release_linux_node_clients == 'true'", + job["if"], + ) + self.assertEqual({"contents": "read"}, job["permissions"]) + self.assertEqual("1.95.0", job["env"]["RUSTUP_TOOLCHAIN"]) + self.assertEqual( + { + ( + "ubuntu-24.04", + "x86_64-unknown-linux-gnu", + "linux-x64-gnu", + ), + ( + "ubuntu-24.04-arm", + "aarch64-unknown-linux-gnu", + "linux-arm64-gnu", + ), + }, + { + (entry["runner"], entry["target"], entry["napi_platform"]) + for entry in job["strategy"]["matrix"]["include"] + }, + ) + setup_node = next( + step for step in job["steps"] if step.get("name") == "Setup Node" + ) + self.assertEqual("22.20.0", setup_node["with"]["node-version"]) + install = next( + step["run"] + for step in job["steps"] + if step.get("name") == "Install pinned Linux client build tools" + ) + self.assertIn("rustup toolchain install 1.95.0 --profile minimal", install) + self.assertIn("--require-hashes --only-binary=:all:", install) + self.assertIn("release/requirements/maturin-1.9.6.txt", install) + proof = next( + step["run"] + for step in job["steps"] + if step.get("name") == "Prove production Linux Node client recipe" + ) + self.assertIn("for client in evidence relay", proof) + self.assertIn("release/scripts/build-linux-node-client", proof) + self.assertIn('--target "${{ matrix.target }}"', proof) + self.assertIn('--napi-platform "${{ matrix.napi_platform }}"', proof) + self.assertIn('--zig-python "${RUNNER_TEMP}/maturin/bin/python"', proof) + self.assertIn("smoke-${client}-client-package.js", proof) + self.assertIn('(cd "${smoke}" && node "smoke-${client}-client-package.js")', proof) + self.assertNotIn("napi build", proof) + self.assertNotIn("npm pack", proof) + self.assertNotIn("docker run", proof) + self.assertFalse(any("upload-artifact@" in str(step) for step in job["steps"])) + self.assertNotIn("contents: write", str(job)) + for forbidden in ("npm publish", "gh release", "docker push"): + self.assertNotIn(forbidden, str(job)) + self.assertIn( + "release-linux-node-clients", + document["jobs"]["ci-result"]["needs"], + ) + + def test_missing_linux_node_release_proof_gates_are_reported(self) -> None: + mutations = ( + ( + "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", + "release_linux_node_clients: false", + "Release Linux Node client path filter", + ), + ( + "release-linux-node-clients:\n name: Release Linux Node clients", + "release-linux-node-clients:\n name: Disabled Linux Node clients", + "Release Linux Node client proof job", + ), + ( + "release/scripts/build-linux-node-client \\", + "release/scripts/disabled-linux-node-client \\", + "Release Linux Node client helper invocation", + ), + ( + '--requirement "${GITHUB_WORKSPACE}/release/requirements/maturin-1.9.6.txt"', + '--requirement "${GITHUB_WORKSPACE}/release/requirements/unpinned.txt"', + "Release Linux Node client pinned tools", + ), + ( + " - release-linux-node-clients", + " - disabled-linux-node-clients", + "Release Linux Node client CI aggregate", + ), + ) + for snippet, replacement, gate in mutations: + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement, 1) + self.assertIn(gate, self.module.missing_gates(text)) + def test_missing_debian13_image_contract_is_reported(self) -> None: text = self.workflow.replace( "run: python3 release/scripts/check-debian13-images.py", @@ -796,6 +896,14 @@ def test_missing_new_release_security_tests_are_reported(self) -> None: "release/scripts/test_release_rehearsal.py", "Release rehearsal workflow tests", ), + ( + "release/scripts/test_build_linux_node_client.py", + "Linux Node client release build helper tests", + ), + ( + "release/scripts/test_zig_glibc_compiler.py", + "Zig glibc compiler wrapper tests", + ), ( "release/scripts/test_verify_public_release.py", "Public release verifier tests", diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index 528f5ac58..f8196dea0 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -26,7 +26,7 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: self.assertIn("request_id:", trigger) self.assertIn("required: true", trigger) self.assertEqual({"contents": "read"}, document["permissions"]) - self.assertEqual(["rehearse"], list(document["jobs"])) + self.assertEqual(["rehearse", "node-clients"], list(document["jobs"])) job = document["jobs"]["rehearse"] self.assertEqual("ubuntu-24.04", job["runs-on"]) self.assertLessEqual(job["timeout-minutes"], 15) @@ -39,6 +39,85 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: ) self.assertNotIn("${{ inputs.", rehearsal["run"]) + clients = document["jobs"]["node-clients"] + self.assertLessEqual(clients["timeout-minutes"], 40) + self.assertEqual( + [ + { + "runner": "ubuntu-24.04", + "asset": "linux-amd64-glibc", + "target": "x86_64-unknown-linux-gnu", + "napi_platform": "linux-x64-gnu", + }, + { + "runner": "ubuntu-24.04-arm", + "asset": "linux-arm64-glibc", + "target": "aarch64-unknown-linux-gnu", + "napi_platform": "linux-arm64-gnu", + }, + ], + clients["strategy"]["matrix"]["include"], + ) + self.assertEqual("Require a branch rehearsal", clients["steps"][0]["name"]) + self.assertFalse( + any("upload-artifact@" in str(step) for step in clients["steps"]) + ) + install = next( + step["run"] + for step in clients["steps"] + if step.get("name") == "Install exact Zig toolchain" + ) + self.assertIn("--require-hashes --only-binary=:all:", install) + self.assertIn("release/requirements/maturin-1.9.6.txt", install) + self.assertIn("-m ziglang version)\" = 0.12.1", install) + + build = next( + step["run"] + for step in clients["steps"] + if step.get("name") == "Build, package, and smoke Linux Node clients" + ) + self.assertIn("for client in evidence relay", build) + helper_call = "release/scripts/build-linux-node-client" + self.assertIn(helper_call, build) + for argument in ( + '--client "${client}"', + '--target "${{ matrix.target }}"', + '--napi-platform "${{ matrix.napi_platform }}"', + '--zig-python "${RUNNER_TEMP}/maturin/bin/python"', + ): + self.assertIn(argument, build) + self.assertLess( + build.index('(cd "${client_dir}" && npm ci)'), + build.index(helper_call), + ) + self.assertIn("node-root-${client}", build) + self.assertIn( + "LICENSE README.md client.js client.d.ts index.js index.d.ts package.json", + build, + ) + self.assertIn("-maxdepth 1 -name '*.node'", build) + self.assertIn( + "node_modules/@registrystack/${client}-client-${{ matrix.napi_platform }}/${client}-client.${{ matrix.napi_platform }}.node", + build, + ) + host_smoke = 'node "smoke-${client}-client-package.js"' + docker_smoke = "docker run --rm --network none" + self.assertLess(build.index(host_smoke), build.index(docker_smoke)) + self.assertIn('"${NODE_GLIBC_BASELINE_IMAGE}"', build) + self.assertRegex( + clients["env"]["NODE_GLIBC_BASELINE_IMAGE"], + r"^node:22\.12\.0-bullseye-slim@sha256:[0-9a-f]{64}$", + ) + for forbidden in ( + "--use-napi-cross", + "upload-artifact@", + "npm publish", + "gh release", + "git tag", + "git push", + ): + self.assertNotIn(forbidden, str(clients)) + def test_script_exercises_future_tag_source_archive_and_dev_base_in_order(self) -> None: text = SCRIPT.read_text(encoding="utf-8") ordered = ( diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 608583f94..3317fc636 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parents[2] WORKFLOWS = ROOT / ".github" / "workflows" LATEST_RELEASE_HELPER = ROOT / "release/scripts/verify_latest_published_release.py" +LINUX_NODE_BUILD_HELPER = ROOT / "release/scripts/build-linux-node-client" def workflow(name: str) -> tuple[str, dict]: @@ -549,7 +550,10 @@ def test_builds_and_smokes_stable_native_client_packages(self) -> None: if step.get("name") == "Restore native client Cargo cache" ) cache_key = cargo_cache["with"]["key"] - self.assertIn("napi-cross-glibc-2.17", cache_key) + self.assertIn("zig-0.12.1-glibc-2.17", cache_key) + self.assertIn("release/requirements/maturin-1.9.6.txt", cache_key) + self.assertIn("release/scripts/zig-glibc-compiler", cache_key) + self.assertIn("release/scripts/build-linux-node-client", cache_key) self.assertIn("crates/registry-evidence-client-node/package-lock.json", cache_key) self.assertIn("crates/registry-relay-client-node/package-lock.json", cache_key) wheel = step_run(document, "clients", "Build Python client wheels") @@ -561,49 +565,78 @@ def test_builds_and_smokes_stable_native_client_packages(self) -> None: self.assertIn("--require-hashes --only-binary=:all:", wheel) self.assertIn("release/requirements/maturin-1.9.6.txt", wheel) node = step_run(document, "clients", "Build Node client packages") - self.assertIn("--use-napi-cross", node) - self.assertIn('--target "${{ matrix.target }}"', node) + self.assertNotIn("--use-napi-cross", node) self.assertIn( - 'export HOST_CC="${{ matrix.target }}-gcc"\n' - ' export HOST_CXX="${{ matrix.target }}-g++"\n' - " napi_args+=(--use-napi-cross)", + "release/scripts/build-linux-node-client \\\n" + ' --client "${client}" \\\n' + ' --target "${{ matrix.target }}" \\\n' + ' --napi-platform "${{ matrix.napi_platform }}" \\\n' + ' --zig-python "${RUNNER_TEMP}/maturin/bin/python"', node, ) - napi_build = ( - '(cd "${client_dir}" && ./node_modules/.bin/napi build ' - '"${napi_args[@]}")' + helper_call = "release/scripts/build-linux-node-client" + self.assertLess( + node.index('(cd "${client_dir}" && npm ci)'), node.index(helper_call) + ) + self.assertLess( + node.index(helper_call), node.index('(cd "${client_dir}" && npm pack') ) - self.assertLess(node.index("export HOST_CC="), node.index(napi_build)) - self.assertLess(node.index("export HOST_CXX="), node.index(napi_build)) + self.assertIn( + '(cd "${client_dir}" && ./node_modules/.bin/napi build \\\n' + ' --platform --release --target "${{ matrix.target }}")', + node, + ) + + helper = LINUX_NODE_BUILD_HELPER.read_text(encoding="utf-8") + self.assertIn('zig_version="$("${zig_python}" -m ziglang version)"', helper) + self.assertIn('if [[ "${zig_version}" != 0.12.1 ]]', helper) + for routed_variable in ( + "HOST_CC", + "HOST_CXX", + "TARGET_CC", + "TARGET_CXX", + ): + self.assertIn(f"export {routed_variable}=", helper) + for routed_variable in ( + 'CC_${target_env}', + 'CXX_${target_env}', + 'CARGO_TARGET_${cargo_target_env}_LINKER', + ): + self.assertIn(f'export "{routed_variable}=', helper) + napi_build = "./node_modules/.bin/napi build" + self.assertIn('--platform --release --target "${rust_target}"', helper) + self.assertNotIn("--use-napi-cross", helper) + self.assertLess(helper.index('export HOST_CC='), helper.index(napi_build)) + self.assertLess(helper.index('export HOST_CXX='), helper.index(napi_build)) self.assertIn( 'unversioned_imports="$(\n' - ' readelf --wide --dyn-syms "${addon}" \\\n' - " | awk '$7 == \"UND\" && $5 != \"WEAK\" && " + ' readelf --wide --dyn-syms "${addon}" \\\n' + " | awk '$7 == \"UND\" && $5 != \"WEAK\" && " "$8 !~ /@/ && $8 !~ /^(napi_|node_api_)/ { print $8 }' \\\n" - " | sort -u\n" - " )\"", - node, + " | sort -u\n" + ")\"", + helper, ) self.assertIn( 'if [[ -n "${unversioned_imports}" ]]; then\n' - " printf 'native addon has strong unversioned imports:" + " printf 'native addon has strong unversioned imports:" "\\n%s\\n' \\\n" - ' "${unversioned_imports}" >&2\n' - " exit 1", - node, + ' "${unversioned_imports}" >&2\n' + " exit 1", + helper, ) - guard_start = node.index('unversioned_imports="$(') - self.assertLess(node.index(napi_build), guard_start) + guard_start = helper.index('unversioned_imports="$(') + self.assertLess(helper.index(napi_build), guard_start) self.assertLess( guard_start, - node.index('(cd "${client_dir}" && npm pack'), + helper.index('readelf --version-info "${addon}"'), ) predicate_marker = "| awk '" - predicate_start = node.index(predicate_marker, guard_start) + len( + predicate_start = helper.index(predicate_marker, guard_start) + len( predicate_marker ) - predicate_end = node.index("' \\", predicate_start) - predicate = node[predicate_start:predicate_end] + predicate_end = helper.index("' \\", predicate_start) + predicate = helper[predicate_start:predicate_end] dynsym_fixtures = { "observed ISO C23 import": ( " 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND " @@ -641,8 +674,8 @@ def test_builds_and_smokes_stable_native_client_packages(self) -> None: check=True, ) self.assertEqual(guard.stdout.splitlines(), expected) - self.assertIn("readelf --version-info", node) - self.assertIn("GLIBC_2.17", node) + self.assertIn("readelf --version-info", helper) + self.assertIn("GLIBC_2.17", helper) self.assertIn( "package/${client}-client.${{ matrix.napi_platform }}.node", node, diff --git a/release/scripts/test_zig_glibc_compiler.py b/release/scripts/test_zig_glibc_compiler.py new file mode 100644 index 000000000..d98e4755e --- /dev/null +++ b/release/scripts/test_zig_glibc_compiler.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +COMPILER = ROOT / "release/scripts/zig-glibc-compiler" + + +class ZigGlibcCompilerTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.log = self.root / "zig.jsonl" + self.python = self.root / "python" + self.python.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "with open(os.environ['ZIG_LOG'], 'a', encoding='utf-8') as log:\n" + " log.write(json.dumps(sys.argv[1:]) + '\\n')\n", + encoding="utf-8", + ) + self.python.chmod(0o755) + self.cc = self.root / "zig-cc" + self.cxx = self.root / "zig-cxx" + self.cc.symlink_to(COMPILER) + self.cxx.symlink_to(COMPILER) + self.env = { + **os.environ, + "REGISTRY_ZIG_PYTHON": str(self.python), + "REGISTRY_ZIG_TARGET": "aarch64-linux-gnu.2.17", + "ZIG_LOG": str(self.log), + } + + def tearDown(self) -> None: + self.temporary.cleanup() + + def run_wrapper( + self, wrapper: Path, *arguments: str, env: dict[str, str] | None = None + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(wrapper), *arguments], + env=env or self.env, + capture_output=True, + text=True, + check=False, + ) + + def logged_calls(self) -> list[list[str]]: + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_dispatches_both_drivers_and_strips_only_incoming_targets(self) -> None: + cc = self.run_wrapper( + self.cc, + "--target=aarch64-unknown-linux-gnu", + "--target", + "ignored-one", + "-target", + "ignored-two", + "-O3", + "source file.c", + "-o", + "output file.o", + ) + cxx = self.run_wrapper(self.cxx, "-std=c++17", "source.cc") + self.assertEqual(cc.returncode, 0, cc.stderr) + self.assertEqual(cxx.returncode, 0, cxx.stderr) + self.assertEqual( + self.logged_calls(), + [ + [ + "-m", + "ziglang", + "cc", + "-target", + "aarch64-linux-gnu.2.17", + "-O3", + "source file.c", + "-o", + "output file.o", + ], + [ + "-m", + "ziglang", + "c++", + "-target", + "aarch64-linux-gnu.2.17", + "-std=c++17", + "source.cc", + ], + ], + ) + + def test_rejects_unapproved_or_ambiguous_configuration(self) -> None: + cases = ( + (COMPILER, (), self.env, "must be invoked"), + ( + self.cc, + (), + {**self.env, "REGISTRY_ZIG_TARGET": "aarch64-linux-gnu.2.28"}, + "approved glibc 2.17 target", + ), + ( + self.cc, + (), + {**self.env, "REGISTRY_ZIG_PYTHON": "python3"}, + "absolute executable", + ), + (self.cc, ("--target",), self.env, "requires a target argument"), + (self.cc, ("-target=override",), self.env, "unsupported target selector"), + ) + for wrapper, arguments, env, message in cases: + with self.subTest(message=message): + result = self.run_wrapper(wrapper, *arguments, env=env) + self.assertEqual(result.returncode, 2) + self.assertIn(message, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/zig-glibc-compiler b/release/scripts/zig-glibc-compiler new file mode 100755 index 000000000..2278db7dc --- /dev/null +++ b/release/scripts/zig-glibc-compiler @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(basename -- "$0")" in + zig-cc) + driver=cc + ;; + zig-cxx) + driver=c++ + ;; + *) + printf 'zig-glibc-compiler must be invoked as zig-cc or zig-cxx\n' >&2 + exit 2 + ;; +esac + +zig_python="${REGISTRY_ZIG_PYTHON:-}" +zig_target="${REGISTRY_ZIG_TARGET:-}" +if [[ "${zig_python}" != /* || ! -x "${zig_python}" ]]; then + printf 'REGISTRY_ZIG_PYTHON must name an absolute executable\n' >&2 + exit 2 +fi +case "${zig_target}" in + x86_64-linux-gnu.2.17|aarch64-linux-gnu.2.17) ;; + *) + printf 'REGISTRY_ZIG_TARGET must name an approved glibc 2.17 target\n' >&2 + exit 2 + ;; +esac + +# cc-rs adds the Rust target even when the selected compiler is a wrapper. +# Remove only its target selector so Zig receives exactly the approved target +# above. Every other compiler and linker argument passes through unchanged. +filtered=() +while (( "$#" )); do + case "$1" in + --target=*) + shift + ;; + --target|-target) + if (( "$#" < 2 )); then + printf '%s requires a target argument\n' "$1" >&2 + exit 2 + fi + shift 2 + ;; + -target=*) + printf 'unsupported target selector: %s\n' "$1" >&2 + exit 2 + ;; + *) + filtered+=("$1") + shift + ;; + esac +done + +exec "${zig_python}" -m ziglang "${driver}" \ + -target "${zig_target}" "${filtered[@]}"