diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index dbe4c9ef..c2094295 100644 --- a/.devflow/features/release-pipeline/KNOWLEDGE.md +++ b/.devflow/features/release-pipeline/KNOWLEDGE.md @@ -188,7 +188,7 @@ Steps (in order): 1. Verify publish credentials (npm `whoami` + cargo token non-empty + PyPI OIDC mint-token exchange). 2. Assert synchronized versions, no `file:` refs. 3. Assert no hazardous codepoints in tracked source. -4. Run `npm run test:gates` — all four spec files, 211 tests including pin-shape specs (S16), per-leg cache key spec (S20), and Alpine load test job spec (S21). +4. Run `npm run test:gates` — all four spec files, 212 tests including pin-shape specs (S16), per-leg cache key spec (S20), Alpine load test job spec (S21), and the cargo-zigbuild musl-leg spec (S22). 5. Assert tagged SHA has green CI history (step-skipped on `pull_request`). Because `npm run test:gates` runs inside `version-gate`, a malformed pin (e.g. a commit SHA @@ -248,6 +248,54 @@ success). `npm publish` calls in `publish-npm` have NO such guard — a partial unrecoverable by re-run. This makes `publish-crates` the single irreversible point of no return; all correctness gates run before it. +### build-napi — musl legs with cargo-zigbuild (#339) + +Both musl matrix entries (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`) use +`napi build --platform --release --target --no-js -x`. The `-x` flag instructs +`@napi-rs/cli` 3.8.6 to run `cargo zigbuild` instead of `cargo build` for non-Windows targets; +it cannot be combined with `--use-napi-cross`. + +**cargo-zigbuild 0.23.0** is pinned via `taiki-e/install-action` at SHA +`6c6fd71fe4fb72c3697d269963d0e15df8adedad` (= v2.85.10, composite action; SHA pin is correct +per PF-040) with `tool: cargo-zigbuild@0.23.0`, `fallback: none`, placed BEFORE +`Swatinem/rust-cache`. Step ordering in `build-napi` (musl legs): + +| Index | Step | Purpose | +|---|---|---| +| 3 | `Install cargo-zigbuild (pinned)` | SHA-pinned taiki-e/install-action, `fallback: none` | +| 4 | `Swatinem/rust-cache` | `key: matrix.settings.target` (PF-041, S20) | +| 5 | `Install zig` | SHA-pinned mlugg/setup-zig, version 0.16.0 | +| 6 | `Assert cargo-zigbuild is the pinned version` | `cargo help zigbuild` + `cargo-zigbuild --version` + 0.23.0 check | +| 9 | `Verify no lingering musl linker export or wrapper (no-op detector)` | reads MUSL_LINKER env vars in `[ -z ]` guards; asserts the wrapper cache absent | +| 10 | `Build addon` | `npx napi build … -x` | +| 11 | `Assert cargo-zigbuild 0.23.0 wrappers were generated` | finds `zigcc-*` under the wrapper cache and re-asserts `cargo-zigbuild --version` | +| 12 | `Assert musl artifact links musl, not glibc` | readelf + ALLOWED_NEEDED allowlist | + +cargo-zigbuild 0.23.0 owns the full linker-arg filter (`--fix-cortex-a53-843419`, +`--no-undefined-version`, `-lgcc_s` to `-lunwind`, self-contained musl CRT skip, response +files) — all of it in `src/zig.rs` (0.23.0 has no `src/zig/` directory; the filter lives +around `run_filter_one`). Its CI tests zig 0.16.0; nothing in 0.23.2–0.23.4 touches +x86_64/aarch64 musl. Bumping zig OR cargo-zigbuild is a deliberate, paired decision. + +Wrapper cache layout (verified against 0.23.0's `cache_dir()` in `src/zig.rs`): +`${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0/wrappers//zigcc--.sh` +(plus `zigcxx-…`, `zigranlib.sh`, and `ar`/`lib` symlinks). Both the pre-build absence +check and the post-build presence check resolve the root with `${XDG_CACHE_HOME:-$HOME/.cache}` +so they name the directory cargo-zigbuild actually writes; the post-build `find` is +recursive because of the `wrappers//` level. Spec S22 pins the XDG-aware form +(a hard-coded `$HOME/.cache` path is rejected, with a planted control). + +The readelf gate uses `ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'` with a planted +`libunwind.so.1` positive control (spec S22, PF-013, PF-038). Spec S22 asserts the +ALLOWED_NEEDED assignment literal (not a comment) contains both patterns. +The expected DT_NEEDED set for a dynamic musl cdylib is just `libc.so`; `libgcc_s.so.1` +is tolerated because `node:22-alpine` ships it. `libunwind.so.1` is the control precisely +because cargo-zigbuild rewrites `-lgcc_s` to `-lunwind` and zig links its own libunwind +into the artifact — a `libunwind.so.1` DT_NEEDED would mean a shared object Alpine does +not ship, invisible to readelf and fatal at dlopen. The allowlist has not yet been observed +against a real cargo-zigbuild-linked artifact; the first dry run is where it earns its keep, +and the Alpine load tests (#340) are the acceptance instrument for any linkage delta. + ### GitHub expression preprocessor trap GitHub's expression preprocessor scans `run:` block text **including shell comments** without @@ -312,9 +360,45 @@ such a file. Never write `${{` in comments; describe it in words. beside `index.js`) and short-circuits the fixture, making the test vacuous. - **Testing the musl addon through `@mdscript/mds`**: its WASM fallback makes the test vacuous — a successful load does not prove the native addon was reached. +- **Installing a pinned cargo binary (e.g. cargo-zigbuild) AFTER `Swatinem/rust-cache`**: + rust-cache deletes every pre-existing `~/.cargo/bin` binary before saving; on a warm cache + hit it restores nothing to `~/.cargo/bin`, so a binary installed after the step is wiped. + Place `taiki-e/install-action` with `fallback: none` BEFORE `Swatinem/rust-cache`. +- **Setting any musl linker export (`CARGO_TARGET_*_MUSL_LINKER`)**: cargo-zigbuild's + `add_env_if_missing` yields to a pre-set `CARGO_TARGET_*_LINKER` value; a leftover export + from an old wrapper step silently reverts the migration with every gate green. The no-op + detector step reads both musl linker vars inside `[ -z "${...:-}" ]` guards before the build. +- **Caching `~/.cache/cargo-zigbuild`**: the wrapper cache is version-keyed + (`~/.cache/cargo-zigbuild//…`); presence after the build proves cargo-zigbuild + 0.23.0 ran in THAT job. Caching it would allow a prior version's wrappers to persist through + an upgrade, defeating the version assertion. ## Gotchas +- **napi's cargo-zigbuild detector is presence-only**: `@napi-rs/cli` checks for zigbuild with + `cargo help zigbuild`; if absent it runs an UNPINNED `cargo install cargo-zigbuild` mid-build, + silently replacing the pinned version. Pre-install with `taiki-e/install-action` and `fallback: + none` (never `fallback: cargo-binstall`), then assert with `cargo-zigbuild --version` AFTER + rust-cache and AGAIN after the build. `cargo help zigbuild` is itself non-vacuous — it exits + 101 when the binary is absent and 0 when it is present (verified locally). +- **`cargo zigbuild --version` is NOT a version probe — it exits 2**: cargo-zigbuild's clap enum + (`src/bin/cargo-zigbuild.rs`) puts `version` on the top-level command and never sets + `propagate_version`, and neither `cargo_options::Build` nor `CommonOptions` defines a + `--version` arg, so `cargo zigbuild --version` (and `-V`) fail with `error: unexpected + argument '--version' found`. Under `set -euo pipefail` that aborts the asserting step and + fails BOTH musl legs on every tag push, dispatch and release-surface PR. Always probe the + binary: `cargo-zigbuild --version` prints exactly `cargo-zigbuild 0.23.0`. S22 asserts the + binary form and rejects the substring `cargo zigbuild --version` anywhere in `build-napi`. +- **`-x` cannot be combined with `--use-napi-cross`**: they are mutually exclusive flags in + `@napi-rs/cli`; combining them is a build error. +- **`add_env_if_missing` in cargo-zigbuild yields to a pre-set `CARGO_TARGET_*_LINKER`**: + any lingering musl linker export (from a prior step or a cached `$GITHUB_ENV` restore) + silently overrides zigbuild's own linker selection. The no-op detector asserts both musl + linker vars are unset before the build, and S22 rejects any SET form anywhere in the file. +- **Version-keyed wrapper cache (`~/.cache/cargo-zigbuild/0.23.0/…`) is fresh per upgrade**: + the path includes the version; an upgrade to 0.23.1 gets a new empty directory. The post-build + step checks `~/.cache/cargo-zigbuild/0.23.0/` to prove the CURRENT version ran (not a cached + older one). Never include `~/.cache/cargo-zigbuild` in `Swatinem/rust-cache`'s `cache-directories`. - **RELEASE_SURFACE set-equality is spec-enforced**: `RELEASE_SURFACE` in `verify-pr-checks.mjs` and `on.pull_request.paths:` in `release.yml` must be identical sets. Spec S10 in `release-auth-probe.spec.mjs` asserts this. If you add a path to one, add it @@ -374,6 +458,10 @@ such a file. Never write `${{` in comments; describe it in words. from the fixture directory — the shape any real non-root cwd has. `musl-load-probe.cjs` asserts `process.cwd() === '/w'` so a dropped flag fails loudly rather than silently returning a spurious "file not found" error. S21 pins `-w /w` in the needle list. +- **`DEBUG` env on `Build addon` was deliberately NOT added**: there is no verified `@napi-rs/cli` + debug namespace to enable, and the wrapper-cache presence check (wrappers found under + `~/.cache/cargo-zigbuild/0.23.0/wrappers/` by the post-build step) is the run-proof that + cargo-zigbuild 0.23.0 actually executed in the job. ## Key Files @@ -389,7 +477,9 @@ such a file. Never write `${{` in comments; describe it in words. - `scripts/__test__/release-auth-probe.spec.mjs` — specs for release.yml structure: pin shape (S16), set equality S10, guard detection, no `${{ }}` literal (S19), `uses:` count (S14), per-leg cache key (S20), cargo token -z guard (S3 extension), Alpine load test - job structure and wiring (S21). + job structure and wiring (S21), cargo-zigbuild musl build: `-x` flag, install-before-cache + ordering, `fallback: none`, no musl linker exports, no-op detector, post-build wrapper + assert, readelf ALLOWED_NEEDED literal, setup-zig SHA pin (S22). - `scripts/__test__/fixtures/protection-main.json` — 6-context branch protection (historical, 2026-08 baseline; kept byte-identical). - `scripts/__test__/fixtures/protection-main-2026-09.json` — 15-context branch protection diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a536590e..121db76d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -241,9 +241,13 @@ jobs: # aarch64-gnu links with the apt cross gcc: --use-napi-cross regressed on # aarch64 in 4e88ebd (the then macOS-generated lockfile did not resolve # @napi-rs/tar-linux-x64-gnu). Not re-tested since; the apt gcc works. - # BOTH musl legs link with a zig cc wrapper because --use-napi-cross has - # no musl toolchain and napi forces -C target-feature=-crt-static on musl, - # so the host gcc would link glibc (see x86_64-unknown-linux-musl). + # BOTH musl legs cross-compile with napi build ... -x (cargo-zigbuild 0.23.0, + # installed by the SHA-pinned install-action BEFORE rust-cache and asserted + # after it) because --use-napi-cross has no musl toolchain and napi forces + # -C target-feature=-crt-static on musl, so the host gcc would silently link + # glibc (v0.1.0-v0.3.0 shipped a glibc-linked linux-x64-musl addon); -x cannot + # be combined with --use-napi-cross; the readelf gate (with its NEEDED allowlist) + # and the Alpine load tests (#340) are the acceptance gates. # macOS x86_64 cross-links from the arm64 host via the installed target. # `--no-js` preserves the hand-written index.js loader (index.d.ts still # regenerates). @@ -267,32 +271,14 @@ jobs: build: napi build --platform --release --target x86_64-unknown-linux-gnu --use-napi-cross --no-js - host: ubuntu-latest target: x86_64-unknown-linux-musl - build: napi build --platform --release --target x86_64-unknown-linux-musl --no-js + # napi -x delegates to cargo zigbuild; cargo-zigbuild owns the linker-arg filter + # (-znostart-stop-gc, self-contained musl CRT skip, response files, etc.) internally + # in src/zig.rs and sets no CARGO_TARGET_*_LINKER. + # napi still injects -C target-feature=-crt-static (dynamic musl, libc.so stays). + # The hand-written zig-cc script and CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER + # export are deleted (#339). Never reintroduce a musl linker export here. + build: napi build --platform --release --target x86_64-unknown-linux-musl --no-js -x use-zig: true - setup: | - # --use-napi-cross supports gnu triples only: @napi-rs/cross-toolchain - # has no musl entry, so napi warned "Unsupported arch: x64 triple: - # x86_64-unknown-linux-musl", swallowed it, and built the musl target - # with the host gcc. Because napi forces -C target-feature=-crt-static - # on every musl ABI (a cdylib cannot use crt-static), rustc's - # self-contained musl libc is off too, so that link resolved -lc - # against the runner's glibc: v0.1.0-v0.3.0 shipped a linux-x64-musl - # addon needing libc.so.6 / GLIBC_2.34 (rust-lang/rust#135244). - # zig cc supplies musl, exactly as on aarch64-musl. No arg - # filter here: of the flags zig cc's allowlist rejects, - # --fix-cortex-a53-843419 is aarch64-only, and - # -Wl,-znostart-stop-gc (also rejected by zig 0.16.0; - # cargo-zigbuild filters both in src/zig/linker_args.rs) - # is gated by rustc to llvm_target x86_64-unknown-linux-gnu - # (rust-lang/rust#137685). If a future rustc widens that - # gate this leg fails with "unsupported linker extension - # flag: -z nostart-stop-gc". - cat > /tmp/zig-cc-x86_64-musl <<'ZIGCC' - #!/bin/sh - exec zig cc -target x86_64-linux-musl "$@" - ZIGCC - chmod +x /tmp/zig-cc-x86_64-musl - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=/tmp/zig-cc-x86_64-musl" >> "$GITHUB_ENV" - host: ubuntu-latest target: aarch64-unknown-linux-gnu build: napi build --platform --release --target aarch64-unknown-linux-gnu --no-js @@ -301,42 +287,14 @@ jobs: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" - host: ubuntu-latest target: aarch64-unknown-linux-musl - build: napi build --platform --release --target aarch64-unknown-linux-musl --no-js + # napi -x delegates to cargo zigbuild; cargo-zigbuild owns the linker-arg filter + # (--fix-cortex-a53-843419, -znostart-stop-gc, -lgcc_s->-lunwind, etc.) internally + # in src/zig.rs and sets no CARGO_TARGET_*_LINKER. + # napi still injects -C target-feature=-crt-static (dynamic musl, libc.so stays). + # The hand-written zig-cc script and CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER + # export are deleted (#339). Never reintroduce a musl linker export here. + build: napi build --platform --release --target aarch64-unknown-linux-musl --no-js -x use-zig: true - setup: | - # rustc 1.98.0 (rust-lang/rust#155453) passes -Wl,--fix-cortex-a53-843419 for - # both aarch64 linux targets. GNU ld accepts it (the -gnu leg builds); zig cc - # rejects any -Wl, arg missing from its own allowlist before lld sees it, and no - # released zig accepts this one (ziglang/zig#36624 on Codeberg, open). lld never - # applies the erratum fix by default, so no zig-linked artifact we have shipped - # ever carried it; stripping restores v0.3.0 behaviour. Same remedy as - # cargo-zigbuild#452. The loop is bounded: exactly $# iterations, order kept. - cat > /tmp/zig-cc-aarch64-musl <<'ZIGCC' - #!/bin/sh - n=$# - i=0 - while [ "$i" -lt "$n" ]; do - arg=$1 - shift - i=$((i + 1)) - case "$arg" in - -Wl,--fix-cortex-a53-843419|--fix-cortex-a53-843419) ;; - *) set -- "$@" "$arg" ;; - esac - done - exec zig cc -target aarch64-linux-musl "$@" - ZIGCC - chmod +x /tmp/zig-cc-aarch64-musl - # Self-check with a fake zig: a silently broken filter must fail here, not at - # link time (positive control: the flag is present on input and must be gone). - mkdir -p /tmp/fakezig - printf '#!/bin/sh\nprintf "%%s\\n" "$@"\n' > /tmp/fakezig/zig - chmod +x /tmp/fakezig/zig - OUT=$(PATH=/tmp/fakezig:$PATH /tmp/zig-cc-aarch64-musl -Wl,--fix-cortex-a53-843419 -o out.so obj.o) - echo "$OUT" | grep -q 843419 && { echo "::error::zig wrapper failed to strip --fix-cortex-a53-843419"; exit 1; } - echo "$OUT" | grep -qx -- '-o' || { echo "::error::zig wrapper dropped unrelated args"; exit 1; } - rm -rf /tmp/fakezig - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/tmp/zig-cc-aarch64-musl" >> "$GITHUB_ENV" - host: windows-latest target: x86_64-pc-windows-msvc build: napi build --platform --release --target x86_64-pc-windows-msvc --no-js @@ -350,6 +308,20 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.settings.target }} + # Install the pinned cargo-zigbuild BEFORE rust-cache (#339): napi's -x path only checks that + # `cargo help zigbuild` succeeds and otherwise runs an unpinned `cargo install cargo-zigbuild` + # (PF-038 shape: existence, never identity); and rust-cache deletes from ~/.cargo/bin every binary + # that pre-dated it before saving, so installing first keeps the pin OUT of the cache. Composite + # action -> SHA pin is correct (PF-040 is about Docker trampolines). fallback: none forbids a + # silent cargo-binstall/source-build substitution. + - name: Install cargo-zigbuild (pinned) + if: matrix.settings.use-zig + uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild@0.23.0 + fallback: none # Per-leg cache key (PF-041, #352). The automatic key is job id + runner # os/arch + a hash of the rustc HOST triple, env and lockfiles; the cross # target is not part of it, so without `key:` all four ubuntu legs and both @@ -364,16 +336,66 @@ jobs: key: ${{ matrix.settings.target }} - name: Install zig if: matrix.settings.use-zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.16.0 + - name: Assert cargo-zigbuild is the pinned version + if: matrix.settings.use-zig + run: | + set -euo pipefail + EXPECTED=0.23.0 + # Positive control (PF-013): the matcher must REJECT a wrong version. + if printf 'cargo-zigbuild 9.9.9\n' | grep -Fqx "cargo-zigbuild ${EXPECTED}"; then echo "::error::positive control FAILED - matcher accepted 9.9.9"; exit 1; fi + echo "positive control OK: the matcher rejects a version that is not the pin" + # Probe the BINARY, never the cargo subcommand. cargo-zigbuild's clap enum sets + # version on the top-level command only and never sets propagate_version, so + # `cargo zigbuild --version` is a parse error - "unexpected argument '--version' + # found", exit 2 - which under set -e would fail this step on every musl leg. + # `cargo-zigbuild --version` prints exactly "cargo-zigbuild " (#339). + ACTUAL=$(cargo-zigbuild --version); echo "cargo-zigbuild --version -> ${ACTUAL}" + printf '%s\n' "${ACTUAL}" | grep -Fqx "cargo-zigbuild ${EXPECTED}" || { echo "::error::cargo-zigbuild is '${ACTUAL}', expected ${EXPECTED} (stale restore or an unpinned install; the linker-arg filter this leg depends on changed, #339)"; exit 1; } + # napi's own predicate (detectCargoBinary): if this fails, napi installs an unpinned copy mid-build. + cargo help zigbuild >/dev/null 2>&1 || { echo "::error::cargo help zigbuild failed - napi would cargo install an unpinned cargo-zigbuild (#339)"; exit 1; } + echo "cargo help zigbuild OK - napi will use the pinned binary" - name: Setup cross-compilation tools if: matrix.settings.setup run: ${{ matrix.settings.setup }} - run: npm ci + - name: Verify no lingering musl linker export or wrapper (no-op detector) + if: matrix.settings.use-zig + run: | + set -euo pipefail + # Resolve the wrapper cache exactly the way cargo-zigbuild does: its cache_dir() + # (src/zig.rs) is dirs::cache_dir() - $XDG_CACHE_HOME when set, else $HOME/.cache - + # with the crate name and version appended. Hard-coding $HOME/.cache would make + # this absence check vacuous on any runner that sets XDG_CACHE_HOME (PF-013). + CZB_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0" + [ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || { echo "::error::CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER is set - cargo-zigbuild's add_env_if_missing would yield to it and silently revert the migration (#339)"; exit 1; } + [ -z "${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || { echo "::error::CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER is set - cargo-zigbuild's add_env_if_missing would yield to it and silently revert the migration (#339)"; exit 1; } + if compgen -G '/tmp/zig-cc-*' > /dev/null; then echo "::error::hand-written zig-cc script present under /tmp - the migration is not in effect (#339)"; exit 1; fi + [ ! -d "$CZB_CACHE" ] || { echo "::error::a cargo-zigbuild 0.23.0 wrapper cache pre-exists at $CZB_CACHE - it must be generated by THIS build to count as proof (#339)"; exit 1; } + echo "no-op detector OK: no musl linker export, no wrapper, no pre-existing cargo-zigbuild cache at $CZB_CACHE" - name: Build addon working-directory: crates/mds-napi run: npx ${{ matrix.settings.build }} + # ~/.cache/cargo-zigbuild is NOT added to rust-cache cache-directories on purpose: + # its presence after the build is the proof that cargo-zigbuild 0.23.0 ran in THIS job + # (not a restored cache from a prior run). The post-build step below asserts it. + - name: Assert cargo-zigbuild 0.23.0 wrappers were generated + if: matrix.settings.use-zig + run: | + set -euo pipefail + # Same resolution rule as the no-op detector (cargo-zigbuild's cache_dir(), src/zig.rs). + CZB_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0" + # cargo-zigbuild writes zigcc--.sh into /wrappers//, + # so the search is recursive. Capture rather than pipe into grep: a missing directory + # then reports as an empty result with a real error message, and there is no + # pipefail/SIGPIPE interaction with a short-circuiting reader. + WRAPPERS=$(find "$CZB_CACHE" -name 'zigcc-*' 2>/dev/null || true) + [ -n "$WRAPPERS" ] || { echo "::error::no cargo-zigbuild 0.23.0 wrappers under $CZB_CACHE - cargo zigbuild did not run in this job (#339)"; exit 1; } + echo "wrappers generated by this build:"; printf '%s\n' "$WRAPPERS" + ACTUAL=$(cargo-zigbuild --version); printf '%s\n' "${ACTUAL}" | grep -Fqx "cargo-zigbuild 0.23.0" || { echo "::error::post-build: cargo-zigbuild is '${ACTUAL}', expected 0.23.0 (napi may have replaced it mid-build, #339)"; exit 1; } + echo "cargo-zigbuild 0.23.0 wrappers generated by this build" - name: Assert musl artifact links musl, not glibc if: ${{ endsWith(matrix.settings.target, '-linux-musl') }} working-directory: crates/mds-napi @@ -403,6 +425,27 @@ jobs: cat /tmp/dyn.txt exit 1 } + # NEEDED allowlist: a dynamic musl cdylib should need musl's libc.so and + # nothing else. libgcc_s.so.1 is tolerated because node:22-alpine ships it + # and the unwind ABI can surface as a DT_NEEDED depending on how the + # unwinder is linked; libunwind.so.1 is NOT tolerated - cargo-zigbuild + # 0.23.0 rewrites -lgcc_s to -lunwind (src/zig.rs) and zig links its own + # libunwind into the artifact, so a libunwind.so.1 DT_NEEDED would mean the + # addon needs a shared object Alpine does not ship and would fail to dlopen + # at runtime, which readelf alone cannot see (PF-038, PF-013). + ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1' + # Positive control (PF-013): the allowlist must REJECT libunwind.so.1. + if ! printf ' 0x0000000000000001 (NEEDED) Shared library: [libunwind.so.1]\n' \ + | grep '(NEEDED)' | grep -vqE "\[(${ALLOWED_NEEDED})\]"; then + echo "::error::positive control FAILED - the allowlist accepted libunwind.so.1"; exit 1 + fi + echo "positive control OK: the NEEDED allowlist rejects libunwind.so.1" + if grep '(NEEDED)' /tmp/dyn.txt | grep -vqE "\[(${ALLOWED_NEEDED})\]"; then + echo "::error::unexpected NEEDED entry: $(grep '(NEEDED)' /tmp/dyn.txt | grep -vE "\[(${ALLOWED_NEEDED})\]" | tr -s ' ') - node:22-alpine may not ship it; this is where the Alpine load test (#340) would fail at runtime" + exit 1 + fi + echo "NEEDED entries (all within allowlist):" + grep '(NEEDED)' /tmp/dyn.txt cat /tmp/dyn.txt - uses: actions/upload-artifact@v7 with: diff --git a/.release/RELEASE-FLOW.md b/.release/RELEASE-FLOW.md index 6bedda67..dd910f56 100644 --- a/.release/RELEASE-FLOW.md +++ b/.release/RELEASE-FLOW.md @@ -88,8 +88,8 @@ publishes nothing. parallel) → stage+verify → publish-crates → publish-npm → publish-python → github-release - **Critical gate**: A3 name↔loader verification (`scripts/verify-napi-names.mjs`) - **Toolchain pins**: Unpinned inputs that have broken release builds: stable rustc (1.98.0 added - `-Wl,--fix-cortex-a53-843419`; aarch64-musl wrapper filters it) and zig (pinned to 0.16.0 in - `release.yml`). See #339 for the durable fix. + `-Wl,--fix-cortex-a53-843419`; cargo-zigbuild 0.23.0 filters this flag internally in `src/zig.rs` + and the hand-written wrappers were removed in #339) and zig (pinned to 0.16.0 in `release.yml`). ## Post-release diff --git a/CHANGELOG.md b/CHANGELOG.md index b854d0e1..59417c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `verify-pr-checks.mjs` suite keying (D-PR8): the skipped-publish allowance and D-PR7 context attribution are now keyed on the check-run's `check_suite.id` mapping to a `release.yml` workflow run (one bounded `GET /actions/runs?head_sha=` call); the verifier exits 2 when it cannot enumerate the head's workflow runs; 2026-09 branch-protection and check fixtures added (`checks-pr366-e02bcf2.json`, `runs-pr366-e02bcf2.json`, `protection-main-2026-09.json`) (#341). - `build-napi` per-leg rust-cache key: adds `key: ${{ matrix.settings.target }}` to the `Swatinem/rust-cache` step so each cross-compile leg's target artifacts stay isolated (PF-041; without the key all four ubuntu legs and both macOS legs restored one shared blob, confirmed live in run 34065573775); `build-python`'s existing `key: matrix.target-matrix.manylinux` (#347) unchanged; spec S20 in `release-auth-probe.spec.mjs` pins both and fails `Version gate` if a key is dropped; spec S3 extended to pin the `-z` CARGO_REG_TOKEN guard in executable code; #345 verified that crates.io `GET /api/v1/me` is `AuthCheck::only_cookie()` (HTTP 403 for any API token) and the only token-accepting read route rejects scoped tokens — non-empty guard is the strongest check available, durable fix tracked in #368; #345 closed won't-fix-as-filed (#345 #352). - Alpine `node:22-alpine` load tests for both musl napi addons gate `publish-crates`: x64 (`linux-x64-musl`) as the last step of `stage-and-verify-napi` (after the staged artifact upload, so the artifact is never suppressed by an x64 failure), arm64 (`linux-arm64-musl`) in a new unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact; both use `scripts/musl-load-probe.cjs` in a `docker run --network none` step with a positive control; `publish-crates` blocks on both via `needs:` AND its `if:` conjunct (PF-047); spec S21 in `release-auth-probe.spec.mjs` pins job existence, runner, guard shape, wiring, step order, and run-block byte-equality (#340); the first CI run surfaced #371 (string compile fails when the base directory is a filesystem root — `node:22-alpine` has no `WORKDIR` so the default container cwd is `/`); the gate now runs the container from `/w` (`docker run -w /w`) and the probe asserts its cwd so a dropped flag fails loudly. +- Both musl napi legs (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`) now cross-compile with `napi build … -x` / cargo-zigbuild 0.23.0: the SHA-pinned `taiki-e/install-action` (v2.85.10, `fallback: none`) installs cargo-zigbuild before `Swatinem/rust-cache` (rust-cache deletes `~/.cargo/bin` on save; napi's detector is presence-only and would `cargo install` an unpinned copy mid-build otherwise); the hand-written zig cc wrappers, fake-zig self-check, and both `CARGO_TARGET_*_MUSL_LINKER` exports are deleted; three new steps assert the pinned version (before and after the build) and the no-op detector reads both musl linker vars inside `[ -z ]` guards to confirm none is set; the readelf gate adds `ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'` with a planted `libunwind.so.1` control; `mlugg/setup-zig` SHA-pinned (v2.2.1) in the same step; spec S22 in `release-auth-probe.spec.mjs` pins all of the above (#339). ## [0.4.2] — 2026-09-03 diff --git a/CLAUDE.md b/CLAUDE.md index e1d5f1d3..90c048da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ See @RELEASING.md for the full runbook. - Workspace panic strategy must stay `unwind` — catch_unwind at the JS/Python FFI boundary requires it - `mds-wasm/Cargo.toml` has explicit (non-inherited) license/repo fields because older wasm-pack parsers fail on workspace inheritance -- Linux cross-builds: only x86_64-gnu uses napi `--use-napi-cross`; aarch64-gnu uses the apt cross gcc; BOTH musl legs use zig cc wrappers, because `--use-napi-cross` has no musl toolchain and napi forces `-C target-feature=-crt-static` on musl, so the host gcc silently links glibc (v0.1.0-v0.3.0 shipped a glibc-linked linux-x64-musl addon). release.yml gates every musl artifact with readelf. +- Linux cross-builds: only x86_64-gnu uses napi `--use-napi-cross`; aarch64-gnu uses the apt cross gcc; both musl legs now cross-compile with `napi build … -x` (cargo-zigbuild 0.23.0, pinned via SHA-pinned `taiki-e/install-action` placed before `Swatinem/rust-cache`; `-x` cannot combine with `--use-napi-cross`; `--use-napi-cross` has no musl toolchain and napi forces `-C target-feature=-crt-static` on musl so the host gcc would silently link glibc — v0.1.0–v0.3.0 shipped a glibc-linked linux-x64-musl addon). release.yml gates every musl artifact with readelf, and both addons are load-tested on `node:22-alpine` before publish. - `cargo publish -p mds-cli --dry-run` fails locally because mds-cli has a path+version dep on mds-core — this is expected; CI publishes mds-core first - `scripts/verify-napi-names.mjs` (A3 gate) is critical — if the hand-written `crates/mds-napi/index.js` loader drifts from generated platform packages, the universal package silently fails to load native binaries at runtime - Stale `.node` files silently serve old behavior — `crates/mds-napi/` can hold multiple addon vintages (`mds-napi.node` from `build:native`, platform-suffixed `mds-napi..node` from `napi build --platform`), and the test harness loads the base `mds-napi.node` by name. After any Rust change, rebuild with `npm run build:native -w @mdscript/mds-napi` or the suite exercises an old binary (PF-035) diff --git a/RELEASING.md b/RELEASING.md index 1a2197d1..303f38ce 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -404,7 +404,7 @@ The `release.yml` workflow runs, in order: ## Notes -- The 7 native napi targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc. x86_64-gnu passes napi's --use-napi-cross; aarch64-gnu links with the apt cross gcc; both musl legs link with zig cc wrappers, and a release gate asserts each musl artifact links musl rather than glibc (see the build-napi matrix in release.yml). zig is pinned to 0.16.0 in release.yml's Install zig step; bump it deliberately, since zig cc's linker-arg allowlist changes between releases. Both musl addons are load-tested on `node:22-alpine` before anything publishes: the x64 load test is the last step of `stage-and-verify-napi` (placed after the staged artifact upload so the artifact is preserved even when the x64 test fails), and the arm64 load test runs in the separate unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact. The fixture is `index.js` + `scripts/musl-load-probe.cjs` + only the musl platform package under `node_modules/@mdscript/`, so a pass is proof the loader's `isMusl()` returned true; a control fixture without the package must fail first (PF-013). `publish-crates` blocks on both via its `needs:` list AND its `if:` conjunct (PF-047). The readelf gate proves ELF metadata (no glibc soname) but not that the addon dlopens on Alpine — a NEEDED entry that Alpine does not ship (e.g. `libunwind.so.1`) is invisible to it; only a real load on `node:22-alpine` catches that (PF-038 shape). When the x64 load test fails, the arm64 job is skipped (its `if:` requires `stage-and-verify-napi` to succeed) and both tests are re-run together after the fix. The container runs with `-w /w` because `node:22-alpine` has no `WORKDIR` and mds-core rejects a filesystem-root base directory (#371, surfaced by this gate's first run on PR #370); `musl-load-probe.cjs` asserts `process.cwd() === '/w'` so a dropped flag fails loudly. +- The 7 native napi targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc. x86_64-gnu passes napi's --use-napi-cross; aarch64-gnu links with the apt cross gcc; both musl legs cross-compile with `napi build … -x` (cargo-zigbuild 0.23.0) — the `-x` flag makes `@napi-rs/cli` 3.8.6 run `cargo zigbuild` instead of `cargo build`; cargo-zigbuild 0.23.0 is installed by the SHA-pinned `taiki-e/install-action` (v2.85.10) with `fallback: none` placed BEFORE `Swatinem/rust-cache` (rust-cache deletes `~/.cargo/bin` binaries on save, so installing after cache would be wiped on a warm hit) and asserted after rust-cache and re-asserted after the build (napi's detector is presence-only — `cargo help zigbuild` — and silently runs an unpinned `cargo install cargo-zigbuild` when zigbuild is absent, reverting the pin); zig 0.16.0 via the SHA-pinned `mlugg/setup-zig`; cargo-zigbuild 0.23.0 owns the full linker-arg filter (`--fix-cortex-a53-843419`, `--no-undefined-version`, `-lgcc_s` to `-lunwind`, self-contained musl CRT skip, response files), its CI tests zig 0.16.0, and nothing in 0.23.2–0.23.4 touches x86_64/aarch64 musl — bumping zig OR cargo-zigbuild is a deliberate, paired decision; never set a musl linker export (`CARGO_TARGET_*_MUSL_LINKER`) — cargo-zigbuild's `add_env_if_missing` yields to a pre-set value and a leftover export would silently revert the migration; the version-keyed wrapper cache (`~/.cache/cargo-zigbuild/0.23.0/…`) is always fresh per version and is deliberately NOT cached (post-build presence proves cargo-zigbuild 0.23.0 ran in that job, not a prior version from cache); the readelf gate uses `ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'` with a planted `libunwind.so.1` positive control, and the Alpine load tests are the acceptance instrument for any linkage delta (a NEEDED soname Alpine does not ship is invisible to readelf — only a real `docker run node:22-alpine` catches it). Both musl addons are load-tested on `node:22-alpine` before anything publishes: the x64 load test is the last step of `stage-and-verify-napi` (placed after the staged artifact upload so the artifact is preserved even when the x64 test fails), and the arm64 load test runs in the separate unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact. The fixture is `index.js` + `scripts/musl-load-probe.cjs` + only the musl platform package under `node_modules/@mdscript/`, so a pass is proof the loader's `isMusl()` returned true; a control fixture without the package must fail first (PF-013). `publish-crates` blocks on both via its `needs:` list AND its `if:` conjunct (PF-047). The readelf gate proves ELF metadata (no glibc soname) but not that the addon dlopens on Alpine — a NEEDED entry that Alpine does not ship (e.g. `libunwind.so.1`) is invisible to it; only a real load on `node:22-alpine` catches that (PF-038 shape). When the x64 load test fails, the arm64 job is skipped (its `if:` requires `stage-and-verify-napi` to succeed) and both tests are re-run together after the fix. The container runs with `-w /w` because `node:22-alpine` has no `WORKDIR` and mds-core rejects a filesystem-root base directory (#371, surfaced by this gate's first run on PR #370); `musl-load-probe.cjs` asserts `process.cwd() === '/w'` so a dropped flag fails loudly. - The 8 Python artifacts (7 `cp311-abi3` wheels + 1 sdist): manylinux x86_64 and aarch64, musllinux_1_2 x86_64 and aarch64, macOS x86_64 and arm64, Windows x86_64, plus one source distribution. Built by `PyO3/maturin-action@v1.51.0` (maturin 1.13.3). The musl and manylinux legs run inside Docker containers that maturin-action manages; the readelf linkage gate asserts the `.so` inside each Linux wheel links the correct libc (musl or glibc), with a positive control and a non-vacuity guard (PF-038). Platform wheels cannot be built or validated locally — use the branch dry-run workflow instead. - wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-sign-ext", ...] is enabled in crates/mds-wasm/Cargo.toml; CI installs wasm-pack and Binaryen v129 via the composite action at .github/actions/setup-wasm/ (version pins live there). Local builds do not need system Binaryen — wasm-pack auto-downloads wasm-opt (v117) on first use; install Binaryen v129+ (brew install binaryen / apt install binaryen) only for offline builds, to override a stale wasm-opt on PATH, or to reproduce CI's exact release optimizer. - Platform packages are generated in CI only — they cannot be validated with a local npm pack; use the dry-run workflow instead. diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index ea16e98d..568d0e50 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -1916,3 +1916,820 @@ describe('B3a: Alpine musl load tests (#340)', () => { }); }); + +// --------------------------------------------------------------------------- +// B3b helpers and S22 spec — cargo-zigbuild musl legs (#339) +// +// Both musl legs of build-napi cross-compile with `napi build … -x` +// (cargo-zigbuild 0.23.0) instead of the hand-written /tmp/zig-cc-* wrapper +// scripts. This spec pins that shape so it cannot silently regress. +// +// Three traps drive the checks: +// Trap 1: napi's cargo-zigbuild detector is presence-only (`cargo help zigbuild`); +// on failure it runs an UNPINNED `cargo install cargo-zigbuild` mid-build. +// Pre-install via install-action with fallback:none prevents the fallback. +// Trap 2: cargo-zigbuild's add_env_if_missing yields to a pre-set +// CARGO_TARGET_*_LINKER, so any leftover musl linker export silently reverts +// the migration while every gate stays green. +// Trap 3: `cargo zigbuild --version` is NOT a version probe. cargo-zigbuild's +// clap enum sets version on the top-level command only and never sets +// propagate_version, so the subcommand form exits 2 with "unexpected argument +// '--version' found" and fails the asserting step on every musl leg. The +// binary form `cargo-zigbuild --version` prints "cargo-zigbuild 0.23.0". +// --------------------------------------------------------------------------- + +/** + * Return an ordered array of step segments from the comment-stripped job section. + * Each entry is { index: number, body: string } where body is the full text of + * that step segment. Steps are segmented at /^ - / boundaries (6-space + * bullet), matching the convention in rustCacheSteps and loadTestRunBlock. + */ +function jobSteps(section) { + const lines = stripCommentLines(section).split('\n'); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (/^ - /.test(lines[i])) starts.push(i); + } + return starts.map((start, n) => ({ + index: n, + body: lines.slice(start, starts[n + 1] ?? lines.length).join('\n'), + })); +} + +/** + * Return the first step in `steps` (from jobSteps) whose body matches `regex`, + * or null when none match. + */ +function stepMatching(steps, regex) { + return steps.find(s => regex.test(s.body)) ?? null; +} + +/** + * Split the comment-stripped build-napi section into matrix settings entries. + * Each entry starts at a 10-space bullet line (the list items under `settings:`). + * Returns an array of multi-line text blocks, one per matrix entry. + * + * Segmentation boundary: /^ - / (10 leading spaces + dash + space). + * This matches the `settings:` list indentation in build-napi but nothing else + * in the section (steps are at 6-space bullets; step fields at 8-space). + */ +function matrixEntries(section) { + const stripped = stripCommentLines(section); + const lines = stripped.split('\n'); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (/^ - /.test(lines[i])) starts.push(i); + } + return starts.map((start, n) => + lines.slice(start, starts[n + 1] ?? lines.length).join('\n'), + ); +} + +describe('B3b: musl legs build with cargo-zigbuild (#339)', () => { + + // ------------------------------------------------------------------------- + // S22: build-napi musl legs use cargo-zigbuild via napi -x, no wrapper residue. + // + // References: #339 (migration), PF-013 (positive-control discipline), + // PF-038 (cross-toolchain flag with no entry for the target falls back silently), + // PF-040 (composite SHA pin correct; Docker-trampoline needs tag pin), + // S20 (per-leg rust-cache key must survive this migration, PF-041). + // ------------------------------------------------------------------------- + test('S22: build-napi musl legs use napi -x (cargo-zigbuild), no zig-cc wrapper residue (PF-013, PF-038, S20, #339)', () => { + + // Word-boundary cross-compile flag matcher. + const CROSS_RE = /(?:^|\s)(-x|--cross-compile)(?:\s|$)/; + // install-action must be SHA-pinned (PF-040: composite action, not Docker-trampoline). + const INSTALL_ACTION_SHA_RE = /uses:\s*taiki-e\/install-action@[0-9a-f]{40}\b/; + // READ form for a musl linker env name: [ -z "${NAME...}" ]. + const MUSL_LINKER_READ_RE = /\[\s*-z\s+"\$\{CARGO_TARGET_[A-Z0-9_]*MUSL[A-Z0-9_]*_LINKER[^}]*\}"/; + // The BROKEN version probe: `cargo zigbuild --version` is a clap parse error + // (exit 2), not a version string. Note the space — `cargo-zigbuild --version` + // (the binary form, which works) does not contain this substring (Trap 3). + const BROKEN_VERSION_PROBE = 'cargo zigbuild --version'; + // Cache path must be resolved the way cargo-zigbuild's cache_dir() does: + // $XDG_CACHE_HOME when set, else $HOME/.cache (src/zig.rs). + const CZB_CACHE_RE = /\$\{XDG_CACHE_HOME:-\$HOME\/\.cache\}\/cargo-zigbuild\/0\.23\.0/; + + // ----------------------------------------------------------------------- + // Parser positive controls (PF-013) — all operate on planted YAML strings. + // All must pass in both RED and GREEN states to prove the helpers work. + // ----------------------------------------------------------------------- + + // PC1: install-action step after rust-cache — the ordering check fires. + // Ordering invariant: install-action index < rust-cache index. + // Planted section has install-action at a higher index than rust-cache. + const plantedOrderViolation = [ + ' fake-job:', + ' steps:', + ' - uses: dtolnay/rust-toolchain@stable', + ' - uses: Swatinem/rust-cache@v2', + ' with:', + ' key: ${{ matrix.settings.target }}', + ' - name: Install zig', + ' uses: mlugg/setup-zig@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ' with:', + ' version: 0.16.0', + ' - name: Install cargo-zigbuild (pinned)', + ' uses: taiki-e/install-action@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ].join('\n'); + const pcOVSteps = jobSteps(plantedOrderViolation); + const pcInstallIdx = stepMatching(pcOVSteps, /taiki-e\/install-action/)?.index ?? -1; + const pcCacheIdx = stepMatching(pcOVSteps, /Swatinem\/rust-cache/)?.index ?? -1; + assert.ok(pcInstallIdx !== -1 && pcCacheIdx !== -1, + 'PC1: planted section must contain both install-action and rust-cache steps'); + assert.ok(pcInstallIdx > pcCacheIdx, + 'PC1: in the planted violation, install-action must be at a higher index than rust-cache'); + // The real check asserts install < cache; planted violation makes it false. + assert.ok(!(pcInstallIdx < pcCacheIdx), + 'PC1: "install < cache" must evaluate to false on the planted violation'); + + // PC2: version-assert step before rust-cache — ordering check fires. + // Invariant: rust-cache index < version-assert index. + // Planted section has version-assert at a lower index than rust-cache. + const plantedVersionBefore = [ + ' fake-job:', + ' steps:', + ' - uses: dtolnay/rust-toolchain@stable', + ' - name: Assert cargo-zigbuild is the pinned version', + ' if: matrix.settings.use-zig', + ' run: |', + ' cargo help zigbuild', + ' cargo-zigbuild --version | grep 0.23.0 # positive control: must match', + ' - uses: Swatinem/rust-cache@v2', + ' with:', + ' key: ${{ matrix.settings.target }}', + ].join('\n'); + const pcVBSteps = jobSteps(plantedVersionBefore); + const pcVBVersionIdx = stepMatching(pcVBSteps, /Assert cargo-zigbuild/)?.index ?? -1; + const pcVBCacheIdx = stepMatching(pcVBSteps, /Swatinem\/rust-cache/)?.index ?? -1; + assert.ok(pcVBVersionIdx !== -1 && pcVBCacheIdx !== -1, + 'PC2: planted section must contain both version-assert and rust-cache steps'); + assert.ok(pcVBVersionIdx < pcVBCacheIdx, + 'PC2: in the planted violation, version-assert must precede rust-cache'); + // The real check asserts cache < version-assert; violation makes it false. + assert.ok(!(pcVBCacheIdx < pcVBVersionIdx), + 'PC2: "cache < version-assert" must be false on the planted violation'); + + // PC3: exec zig cc only in a comment — no residue hit after stripCommentLines. + const plantedZigComment = [ + ' fake-job:', + ' steps:', + ' - name: some step', + ' run: |', + ' # exec zig cc -target x86_64-linux-musl "$@"', + ' echo "clean"', + ].join('\n'); + assert.ok( + !stripCommentLines(plantedZigComment).includes('exec zig cc'), + 'PC3: "exec zig cc" only in a comment must not appear after stripCommentLines (PF-013)', + ); + + // PC4: build line without -x is not matched by CROSS_RE. + const plantedBuildNoX = + 'napi build --platform --release --target x86_64-unknown-linux-musl --no-js'; + assert.ok( + !CROSS_RE.test(plantedBuildNoX), + 'PC4: a musl build line without -x must not match the cross-compile regex', + ); + + // PC5: -xyz suffix does NOT satisfy the word-boundary -x matcher; -x with + // surrounding spaces does. + const plantedBuildXyz = + 'napi build --platform --release --target x86_64-unknown-linux-musl -xyz'; + assert.ok( + !CROSS_RE.test(plantedBuildXyz), + 'PC5: "-xyz" must not match CROSS_RE — it is not the -x flag but a longer option', + ); + assert.ok( + CROSS_RE.test('napi build --target foo -x --no-js'), + 'PC5: "-x" with surrounding whitespace must match CROSS_RE', + ); + assert.ok( + CROSS_RE.test('napi build --target foo --cross-compile'), + 'PC5: "--cross-compile" at end of line must match CROSS_RE', + ); + + // PC6: tag pin rejected by SHA regex; 40-hex SHA accepted. + assert.ok( + !INSTALL_ACTION_SHA_RE.test( + ' - uses: taiki-e/install-action@v2', + ), + 'PC6: tag pin "taiki-e/install-action@v2" must be rejected by INSTALL_ACTION_SHA_RE (PF-040)', + ); + assert.ok( + INSTALL_ACTION_SHA_RE.test( + ' - uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad', + ), + 'PC6: a 40-hex SHA pin must be accepted by INSTALL_ACTION_SHA_RE', + ); + + // PC7: READ form accepted; SET form (export / GITHUB_ENV) not accepted as READ. + const plantedMuslRead = + '[ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER:-}" ]'; + const plantedMuslSet = + 'export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=/tmp/x'; + assert.ok( + MUSL_LINKER_READ_RE.test(plantedMuslRead), + 'PC7: [ -z "${CARGO_TARGET_*MUSL*_LINKER:-}" ] must match MUSL_LINKER_READ_RE', + ); + assert.ok( + !MUSL_LINKER_READ_RE.test(plantedMuslSet), + 'PC7: export CARGO_TARGET_*MUSL*_LINKER=... must NOT match MUSL_LINKER_READ_RE ' + + '(any assignment is a SET, not a READ)', + ); + + // PC8: readelf body without ALLOWED_NEEDED is flagged. + const plantedReadelfNoAllowed = [ + "GLIBC_RE='libc\\.so\\.6|ld-linux'", + 'readelf -d "$node_file" > /tmp/dyn.txt', + 'grep -q NEEDED /tmp/dyn.txt', + ].join('\n'); + assert.ok( + !plantedReadelfNoAllowed.includes('ALLOWED_NEEDED'), + 'PC8: a readelf step body without ALLOWED_NEEDED must be detectable ' + + '(proves the assertion cannot pass vacuously)', + ); + + // PC(B3a): ALLOWED_NEEDED with only libc.so (no libgcc_s.so.1) must be detectable — + // proves the libgcc_s regex pins to the variable value, not an inline comment. + const plantedReadelfLibcOnly = "ALLOWED_NEEDED='libc\\.so'"; + assert.ok( + !/ALLOWED_NEEDED='[^']*libgcc_s\\\.so\\\.1[^']*'/.test(plantedReadelfLibcOnly), + "PC(B3a): ALLOWED_NEEDED='libc\\.so' (no libgcc_s.so.1) must not pass the libgcc_s " + + 'assertion — proves the regex cannot be satisfied by an inline comment alone', + ); + + // PC9 (Trap 3): the subcommand form of the version probe must be detectable, and + // the working binary form must NOT trip the detector. `cargo zigbuild --version` + // exits 2 with "unexpected argument '--version' found" — a step that runs it under + // `set -e` fails on every musl leg, so it must never appear in the workflow. + const plantedBrokenProbe = 'ACTUAL=$(cargo zigbuild --version); echo "${ACTUAL}"'; + const plantedWorkingProbe = 'ACTUAL=$(cargo-zigbuild --version); echo "${ACTUAL}"'; + assert.ok( + plantedBrokenProbe.includes(BROKEN_VERSION_PROBE), + 'PC9: the subcommand form "cargo zigbuild --version" must be detectable (PF-013)', + ); + assert.ok( + !plantedWorkingProbe.includes(BROKEN_VERSION_PROBE), + 'PC9: "cargo-zigbuild --version" (the binary form that actually prints a version) ' + + 'must NOT match the broken-probe needle — the hyphen is what distinguishes them', + ); + assert.ok( + plantedWorkingProbe.includes('cargo-zigbuild --version'), + 'PC9: the binary form needle must match the planted working probe', + ); + // `cargo help zigbuild` (napi's own presence predicate) must not be confused with + // the broken version probe — it is a different command and stays required. + assert.ok( + !'cargo help zigbuild'.includes(BROKEN_VERSION_PROBE), + 'PC9: "cargo help zigbuild" must not match the broken-probe needle', + ); + + // PC10 (Trap 2): every SET form of a musl linker env must fail MUSL_LINKER_READ_RE, + // not just the `export NAME=` form covered by PC7. A bare assignment, a + // $GITHUB_ENV append, and a YAML `env:` mapping key are all SETs. + for (const [label, plantedSet] of [ + ['bare assignment', 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/tmp/zig-cc-aarch64-musl'], + ['GITHUB_ENV append', + 'echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=/tmp/x" >> "$GITHUB_ENV"'], + ['YAML env: mapping key', + ' CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: /tmp/zig-cc-x86_64-musl'], + ]) { + assert.ok( + /CARGO_TARGET_[A-Z0-9_]*MUSL[A-Z0-9_]*_LINKER/.test(plantedSet), + `PC10: the planted ${label} must be collected by the musl-linker line filter ` + + '(otherwise the READ assertion below never sees it — vacuous, PF-013)', + ); + assert.ok( + !MUSL_LINKER_READ_RE.test(plantedSet), + `PC10: a ${label} SET form must NOT satisfy MUSL_LINKER_READ_RE — ` + + "cargo-zigbuild's add_env_if_missing yields to any pre-set linker env (Trap 2)", + ); + } + + // PC11: the wrapper-cache path must be XDG-aware. A hard-coded $HOME/.cache form + // checks a directory cargo-zigbuild may never touch, which would make the + // pre-build absence assertion vacuous (PF-013). + assert.ok( + CZB_CACHE_RE.test('CZB_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0"'), + 'PC11: the XDG-aware cache expression must match CZB_CACHE_RE', + ); + assert.ok( + !CZB_CACHE_RE.test('CZB_CACHE="$HOME/.cache/cargo-zigbuild/0.23.0"'), + 'PC11: a hard-coded $HOME/.cache path must NOT match CZB_CACHE_RE — ' + + "cargo-zigbuild's cache_dir() honours $XDG_CACHE_HOME first (src/zig.rs)", + ); + + // ----------------------------------------------------------------------- + // Real-file assertions — these pin the end state of the cargo-zigbuild migration. + // The first of them is the -x check on the musl build lines. + // ----------------------------------------------------------------------- + + const buildNapiSection = extractJobSection(yml, 'build-napi'); + assert.ok(buildNapiSection !== null, 'S22 non-vacuity: build-napi must exist in release.yml'); + + const strippedSection = stripCommentLines(buildNapiSection); + const steps = jobSteps(buildNapiSection); + assert.ok(steps.length > 0, 'S22 non-vacuity: build-napi must have steps (jobSteps non-empty)'); + + // --- Matrix build: lines --- + + // Collect all `build: napi build …` lines from the comment-stripped section. + const buildLineMatches = [...strippedSection.matchAll(/^\s+build:\s+(napi build .+)$/gm)]; + const buildLines = buildLineMatches.map(m => m[1].trim()); + + assert.equal( + buildLines.length, 7, + `S22: build-napi matrix must have exactly 7 build: lines (one per target); ` + + `found ${buildLines.length}: ${JSON.stringify(buildLines)}`, + ); + + // Identify the two musl build lines by --target value ending in -linux-musl. + const muslBuildLines = buildLines.filter(b => { + const m = /--target\s+(\S+)/.exec(b); + return m !== null && m[1].endsWith('-linux-musl'); + }); + + assert.equal( + muslBuildLines.length, 2, + `S22: exactly 2 build: lines must target a -linux-musl triple; ` + + `found ${muslBuildLines.length}: ${JSON.stringify(muslBuildLines)}`, + ); + + // Each musl build line must carry -x or --cross-compile. + // This is the FIRST REAL-FILE assertion to fail on RED (current file has no -x). + for (const line of muslBuildLines) { + assert.ok( + CROSS_RE.test(line), + `S22: musl build line must include -x or --cross-compile (cargo-zigbuild flag, #339); ` + + `got: "${line}" — each musl napi build command ends with " -x"`, + ); + } + + // No musl build line may carry --use-napi-cross (PF-038: napi's cross-toolchain + // has no musl entry and silently falls back to the host glibc linker). + for (const line of muslBuildLines) { + assert.ok( + !line.includes('--use-napi-cross'), + `S22: musl build line must NOT contain --use-napi-cross (PF-038: napi warns ` + + `"Unsupported arch" and falls back to host glibc, shipping a glibc-linked musl addon); ` + + `got: "${line}"`, + ); + } + + // Matrix entry checks: each musl entry must carry use-zig: true and no setup: key. + // Entries are segmented at 10-space bullet boundaries (see matrixEntries docs). + const entries = matrixEntries(buildNapiSection); + assert.ok(entries.length > 0, 'S22 non-vacuity: matrixEntries must return at least one entry'); + + const muslEntries = entries.filter(e => { + const bm = /build:\s+(napi build .+)$/m.exec(e); + if (!bm) return false; + const tm = /--target\s+(\S+)/.exec(bm[1]); + return tm !== null && tm[1].endsWith('-linux-musl'); + }); + + assert.equal( + muslEntries.length, 2, + `S22: exactly 2 matrix entries must have a musl --target; found ${muslEntries.length}`, + ); + + for (const entry of muslEntries) { + assert.ok( + entry.includes('use-zig: true'), + `S22: each musl matrix entry must carry "use-zig: true" — the install-action and ` + + `Install zig steps are guarded by matrix.settings.use-zig; got entry:\n${entry}`, + ); + assert.ok( + !entry.includes('setup:'), + `S22: musl matrix entries must NOT have a "setup:" key — the migration removed the ` + + `hand-written zig-cc wrapper scripts and the setup: block that created them; ` + + `got entry:\n${entry}`, + ); + } + + // --- Residue checks --- + // None of the old wrapper artifacts may remain in the comment-stripped section, + // except zig-cc- inside the no-op detector step's /tmp/zig-cc-* absence assertion. + + // Find the no-op detector step (if present) to carve out its body before + // checking zig-cc- — the detector itself is allowed to reference /tmp/zig-cc- + // as the thing it is asserting absent. + const nopDetectorStep = stepMatching( + steps, + /CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER/, + ); + const nopBody = nopDetectorStep?.body ?? ''; + + assert.ok( + !strippedSection.includes('exec zig cc'), + 'S22: "exec zig cc" must not appear in build-napi — ' + + 'the hand-written zig-cc wrapper scripts (x86_64-musl and aarch64-musl) are ' + + 'replaced by cargo-zigbuild (#339)', + ); + + assert.ok( + !strippedSection.includes('ZIGCC'), + 'S22: "ZIGCC" heredoc marker must not appear in build-napi (#339)', + ); + + // zig-cc- may appear only in the no-op detector step's /tmp/zig-cc- absence assertion. + // Carve out the nop body (empty string when the step does not exist) before checking. + const strippedOutsideNop = strippedSection.replace(nopBody, ''); + assert.ok( + !strippedOutsideNop.includes('zig-cc-'), + 'S22: "zig-cc-" must not appear in build-napi outside the no-op detector step (#339); ' + + 'the /tmp/zig-cc-* form is allowed ONLY in that step as an absence assertion', + ); + + assert.ok( + !strippedSection.includes('fakezig'), + 'S22: "fakezig" (the aarch64 wrapper self-check helper) must not appear in ' + + 'build-napi (#339)', + ); + + assert.ok( + !strippedSection.includes('843419'), + 'S22: "843419" (--fix-cortex-a53-843419) must not appear in build-napi — ' + + 'cargo-zigbuild filters this flag internally in src/zig.rs; the wrapper ' + + 'loop that stripped it is removed (#339)', + ); + + // Every CARGO_TARGET_*MUSL*_LINKER occurrence must be (a) a READ ([ -z form) AND + // (b) lie inside the no-op detector step — not in "Build addon" or any other step. + // A READ guard outside the detector passes the form check but does not abort before + // the build, losing the migration's pre-build protection (Trap 2 from #339). + // + // PC12: step-scoping planted controls. + // (a) READ form inside "Build addon" step — form passes but scoping must FAIL. + const plantedReadInAddon = [ + ' fake-job:', + ' steps:', + ' - name: Verify no lingering musl linker export or wrapper (no-op detector)', + ' if: matrix.settings.use-zig', + ' run: |', + ' echo "no linker guard here"', + ' - name: Build addon', + ' working-directory: crates/mds-napi', + ' run: |', + ' [ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || exit 1', + ].join('\n'); + const pc12aSteps = jobSteps(plantedReadInAddon); + const pc12aDetector = stepMatching(pc12aSteps, /Verify no lingering musl linker export/); + const pc12aStripped = stripCommentLines(plantedReadInAddon); + const pc12aMuslLines = pc12aStripped.split('\n').filter(l => + /CARGO_TARGET_[A-Z0-9_]*MUSL[A-Z0-9_]*_LINKER/.test(l), + ); + assert.ok( + pc12aMuslLines.length === 1, + 'PC12a: planted section must have exactly one MUSL_LINKER line in Build addon', + ); + assert.ok( + MUSL_LINKER_READ_RE.test(pc12aMuslLines[0]), + 'PC12a: the planted MUSL_LINKER line must pass the READ form check ' + + '(proves form-pass alone is insufficient for the scoping gate to pass)', + ); + assert.ok( + pc12aDetector !== null && !pc12aDetector.body.includes(pc12aMuslLines[0].trim()), + 'PC12a: the detector step must NOT contain the planted READ line ' + + '(line is in "Build addon" — the scoping gate must reject a READ outside the detector)', + ); + // (b) READ form inside the no-op detector step — scoping must PASS. + const plantedReadInDetector = [ + ' fake-job:', + ' steps:', + ' - name: Verify no lingering musl linker export or wrapper (no-op detector)', + ' if: matrix.settings.use-zig', + ' run: |', + ' [ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || exit 1', + ' - name: Build addon', + ' working-directory: crates/mds-napi', + ' run: |', + ' echo "build here"', + ].join('\n'); + const pc12bSteps = jobSteps(plantedReadInDetector); + const pc12bDetector = stepMatching(pc12bSteps, /Verify no lingering musl linker export/); + const pc12bStripped = stripCommentLines(plantedReadInDetector); + const pc12bMuslLines = pc12bStripped.split('\n').filter(l => + /CARGO_TARGET_[A-Z0-9_]*MUSL[A-Z0-9_]*_LINKER/.test(l), + ); + assert.ok( + pc12bMuslLines.length === 1, + 'PC12b: planted section must have exactly one MUSL_LINKER line in the detector step', + ); + assert.ok( + pc12bDetector !== null && pc12bDetector.body.includes(pc12bMuslLines[0].trim()), + 'PC12b: the detector step MUST contain the planted READ line ' + + '(line is in the detector — the scoping gate must accept a READ inside the detector)', + ); + + // Real-file check: form + step-scope enforcement. + const muslLinkerLines = strippedSection.split('\n').filter(l => + /CARGO_TARGET_[A-Z0-9_]*MUSL[A-Z0-9_]*_LINKER/.test(l), + ); + const detectorStepByName = stepMatching(steps, /Verify no lingering musl linker export/); + for (const line of muslLinkerLines) { + assert.ok( + MUSL_LINKER_READ_RE.test(line), + `S22: every CARGO_TARGET_*MUSL*_LINKER line must be a READ ([ -z form) — ` + + `any export or >> "$GITHUB_ENV" form silently reverts the cargo-zigbuild migration ` + + `(cargo-zigbuild's add_env_if_missing yields to a pre-set linker env, Trap 2 from #339); ` + + `got: "${line}"`, + ); + assert.ok( + detectorStepByName !== null && detectorStepByName.body.includes(line.trim()), + `S22: every CARGO_TARGET_*MUSL*_LINKER READ line must lie inside the no-op detector ` + + `step ("Verify no lingering musl linker export or wrapper") — a READ guard in any ` + + `other step (e.g. "Build addon") does not abort before the build and loses the ` + + `migration's pre-build protection (Trap 2 from #339); got line: "${line.trim()}"`, + ); + } + + // Non-vacuity: the GNU linker export must still be present so the MUSL regex is non-vacuous. + assert.ok( + strippedSection.includes('CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER'), + 'S22 non-vacuity: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER must still appear ' + + 'in build-napi (aarch64-gnu uses the apt cross gcc, not cargo-zigbuild) — proves ' + + 'the MUSL_LINKER_READ_RE is not matching the GNU export', + ); + + // --- Pinned install-action step --- + + const installStep = stepMatching(steps, INSTALL_ACTION_SHA_RE); + assert.ok( + installStep !== null, + 'S22: build-napi must contain a taiki-e/install-action step pinned to a 40-hex SHA ' + + '(PF-040: SHA-pinning composite actions is correct hardening; install-action is ' + + 'composite, not Docker-trampoline); ' + + 'steps found: [' + steps.map(s => s.body.split('\n')[0].trim()).join(' | ') + ']', + ); + + assert.ok( + installStep.body.includes('tool: cargo-zigbuild@0.23.0'), + `S22: install-action step must specify tool: cargo-zigbuild@0.23.0; ` + + `got step:\n${installStep.body}`, + ); + + assert.ok( + installStep.body.includes('fallback: none'), + `S22: install-action step must set "fallback: none" — without this, if the ` + + `pre-installed binary is absent napi runs an UNPINNED "cargo install cargo-zigbuild" ` + + `mid-build, violating the version pin (Trap 1 from #339); got step:\n${installStep.body}`, + ); + + assert.ok( + installStep.body.includes('GITHUB_TOKEN'), + `S22: install-action step must pass GITHUB_TOKEN in env:; got step:\n${installStep.body}`, + ); + + assert.ok( + installStep.body.includes('if: matrix.settings.use-zig'), + `S22: install-action step must be guarded by "if: matrix.settings.use-zig" so it ` + + `only runs on musl legs; got step:\n${installStep.body}`, + ); + + // --- Ordering: install-action < rust-cache < Install zig < version-assert --- + + const rustCacheStep = stepMatching(steps, /Swatinem\/rust-cache@/); + const installZigStep = stepMatching(steps, /name: Install zig/); + const versionAssertStep = stepMatching( + steps, + /Assert cargo-zigbuild is the pinned version/, + ); + + assert.ok(rustCacheStep !== null, + 'S22 non-vacuity: build-napi must have a Swatinem/rust-cache step'); + assert.ok(installZigStep !== null, + 'S22 non-vacuity: build-napi must have an "Install zig" step'); + assert.ok( + versionAssertStep !== null, + 'S22: build-napi must contain an "Assert cargo-zigbuild is the pinned version" step ' + + '(if: matrix.settings.use-zig) that verifies cargo-zigbuild identity after install', + ); + + assert.ok( + installStep.index < rustCacheStep.index, + `S22: "Install cargo-zigbuild (pinned)" (index ${installStep.index}) must precede ` + + `Swatinem/rust-cache (index ${rustCacheStep.index}) — rust-cache deletes ~/.cargo/bin ` + + `before saving the cache, so a binary installed AFTER rust-cache is evicted on next ` + + `warm restore (#339)`, + ); + + assert.ok( + rustCacheStep.index < installZigStep.index, + `S22: Swatinem/rust-cache (index ${rustCacheStep.index}) must precede ` + + `"Install zig" (index ${installZigStep.index})`, + ); + + assert.ok( + installZigStep.index < versionAssertStep.index, + `S22: "Install zig" (index ${installZigStep.index}) must precede ` + + `"Assert cargo-zigbuild is the pinned version" (index ${versionAssertStep.index})`, + ); + + // version-assert step body: required probes and a positive control. + assert.ok( + versionAssertStep.body.includes('cargo help zigbuild'), + 'S22: version-assert step must call "cargo help zigbuild" — napi uses this as its ' + + 'presence detector; without it napi falls back to UNPINNED cargo install (Trap 1)', + ); + assert.ok( + versionAssertStep.body.includes('cargo-zigbuild --version'), + 'S22: version-assert step must call "cargo-zigbuild --version" (the BINARY form) ' + + 'to read the version', + ); + assert.ok( + versionAssertStep.body.includes('0.23.0'), + 'S22: version-assert step must assert version string "0.23.0"', + ); + const versionPCLines = stripCommentLines(versionAssertStep.body) + .split('\n') + .filter(l => l.includes('positive control')); + assert.ok( + versionPCLines.length >= 1, + `S22: version-assert step must contain at least 1 non-comment line with ` + + `"positive control" (PF-013: a step that cannot reject a wrong version is vacuous); ` + + `found ${versionPCLines.length}`, + ); + + // --- No-op detector step --- + // Must exist before Build addon; must check both musl linker names in [ -z ] form; + // must assert absence of .cache/cargo-zigbuild and /tmp/zig-cc-*. + + const buildAddonStep = stepMatching(steps, /name: Build addon/); + assert.ok( + buildAddonStep !== null, + 'S22 non-vacuity: build-napi must have a "Build addon" step', + ); + + assert.ok( + nopDetectorStep !== null, + 'S22: build-napi must contain a no-op detector step (before "Build addon") that ' + + 'checks CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER in a [ -z ] test — any ' + + 'leftover musl linker export silently reverts the cargo-zigbuild migration (Trap 2)', + ); + + assert.ok( + nopDetectorStep.index < buildAddonStep.index, + `S22: no-op detector step (index ${nopDetectorStep.index}) must precede ` + + `"Build addon" (index ${buildAddonStep.index})`, + ); + + assert.ok( + nopDetectorStep.body.includes('CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER'), + 'S22: no-op detector must also check CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER', + ); + + // Both musl linker names must appear inside [ -z ] tests. + for (const name of [ + 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER', + 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER', + ]) { + assert.ok( + nopDetectorStep.body.includes(`[ -z "\${${name}`), + `S22: no-op detector must check ${name} with a [ -z "\${${name}...}" ] guard`, + ); + } + + // Absence assertion: the cargo-zigbuild wrapper cache must not exist before the build. + assert.ok( + nopDetectorStep.body.includes('cargo-zigbuild/0.23.0') && + (nopDetectorStep.body.includes('! -d') || nopDetectorStep.body.includes('! -e')), + 'S22: no-op detector must assert absence of the cargo-zigbuild/0.23.0 wrapper cache ' + + '(! -d or ! -e) before "Build addon" — proves cargo-zigbuild has not yet run at this point', + ); + + // Absence assertion: /tmp/zig-cc-* wrapper scripts must not exist. + assert.ok( + nopDetectorStep.body.includes('/tmp/zig-cc-'), + 'S22: no-op detector must assert absence of /tmp/zig-cc-* wrapper scripts', + ); + + // --- Post-build step --- + // Must exist after Build addon; must confirm .cache/cargo-zigbuild/0.23.0. + + const postBuildStep = + steps.find(s => + s.index > buildAddonStep.index && + s.body.includes('cargo-zigbuild/0.23.0'), + ) ?? null; + + assert.ok( + postBuildStep !== null, + 'S22: build-napi must contain a post-build step (after "Build addon") that asserts ' + + 'the cargo-zigbuild/0.23.0 wrapper cache is present — proves cargo-zigbuild 0.23.0 ' + + '(not another version) ran in this specific job (#339)', + ); + + assert.ok( + postBuildStep.body.includes('cargo-zigbuild --version'), + 'S22: post-build step must call "cargo-zigbuild --version" (the BINARY form) to ' + + 're-assert the version after the build', + ); + + // Trap 3: neither asserting step may use the subcommand form anywhere in build-napi. + // `cargo zigbuild --version` exits 2 ("unexpected argument '--version' found") because + // cargo-zigbuild's clap enum never sets propagate_version, so under `set -e` it fails + // BOTH musl legs of build-napi on every tag push, dispatch and release-surface PR. + assert.ok( + !strippedSection.includes(BROKEN_VERSION_PROBE), + `S22: "${BROKEN_VERSION_PROBE}" must not appear in build-napi — the cargo subcommand ` + + `form is a clap parse error (exit 2), not a version probe; use the binary form ` + + `"cargo-zigbuild --version", which prints "cargo-zigbuild 0.23.0" (Trap 3, #339)`, + ); + + // Both cache-path steps must resolve the wrapper cache the way cargo-zigbuild does. + for (const [label, step] of [ + ['no-op detector', nopDetectorStep], + ['post-build wrapper assert', postBuildStep], + ]) { + assert.match( + step.body, + CZB_CACHE_RE, + `S22: the ${label} step must resolve the wrapper cache as ` + + '"${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0" — cargo-zigbuild\'s ' + + 'cache_dir() (src/zig.rs) honours $XDG_CACHE_HOME first, and a hard-coded ' + + '$HOME/.cache path would check a directory cargo-zigbuild never writes (PF-013)', + ); + } + + // --- readelf gate (name contains "links musl, not glibc") --- + + const readelfStep = stepMatching(steps, /links musl, not glibc/); + assert.ok( + readelfStep !== null, + 'S22 non-vacuity: build-napi must have a step whose name/body contains "links musl, not glibc"', + ); + + assert.ok( + readelfStep.body.includes('ALLOWED_NEEDED'), + 'S22: readelf gate must define ALLOWED_NEEDED — cargo-zigbuild links libunwind.so.1 ' + + 'instead of libgcc_s.so.1 (dynamic exception unwind ABI); a plain "no glibc" check ' + + 'is not sufficient; the allowlist enumerates expected NEEDED entries (PF-038)', + ); + + assert.match( + readelfStep.body, + /ALLOWED_NEEDED='[^']*libgcc_s\\\.so\\\.1[^']*'/, + 'S22: readelf gate ALLOWED_NEEDED must contain the escaped pattern libgcc_s\\.so\\.1 ' + + 'in the variable assignment literal (PF-038)', + ); + + assert.match( + readelfStep.body, + /ALLOWED_NEEDED='[^']*libc\\\.so[^']*'/, + 'S22: readelf gate ALLOWED_NEEDED must contain the escaped pattern libc\\.so ' + + 'in the variable assignment literal (PF-038)', + ); + + assert.ok( + readelfStep.body.includes('libunwind.so.1'), + 'S22: readelf gate must include libunwind.so.1 as a planted positive control — ' + + 'an unexpected NEEDED soname would only fail at runtime in the Alpine load tests; ' + + 'planting it here proves the ALLOWED_NEEDED check is non-vacuous (PF-013, PF-038)', + ); + + // --- Install zig step: SHA-pinned, version 0.16.0, guarded by use-zig --- + + assert.ok( + /uses:\s*mlugg\/setup-zig@[0-9a-f]{40}/.test(installZigStep.body), + `S22: "Install zig" step must pin mlugg/setup-zig to a 40-hex SHA (PF-040 — ` + + `composite action; SHA-pinning is correct here, not a tag); ` + + `got step:\n${installZigStep.body}`, + ); + + assert.ok( + installZigStep.body.includes('version: 0.16.0'), + `S22: "Install zig" step must pin zig to version 0.16.0; got step:\n${installZigStep.body}`, + ); + + assert.ok( + installZigStep.body.includes('if: matrix.settings.use-zig'), + `S22: "Install zig" step must be guarded by "if: matrix.settings.use-zig"; ` + + `got step:\n${installZigStep.body}`, + ); + + // S20 reuse: rust-cache key must still include matrix.settings.target (PF-041, #352). + const cacheSteps = rustCacheSteps(buildNapiSection); + assert.ok(cacheSteps.length > 0, + 'S22 reuse S20: build-napi must have at least one Swatinem/rust-cache step'); + for (const step of cacheSteps) { + assert.ok( + step.key !== null && step.key.includes('matrix.'), + `S22 reuse S20: build-napi rust-cache key must include "matrix." (PF-041, #352) — ` + + `this migration must not drop the per-leg cache key; got key: ${JSON.stringify(step.key)}`, + ); + } + + // Non-vacuity: findAllJobIds includes build-napi; residue needle list is non-empty. + assert.ok( + findAllJobIds(yml).includes('build-napi'), + 'S22 non-vacuity: findAllJobIds must include build-napi', + ); + const residueNeedles = ['exec zig cc', 'ZIGCC', 'zig-cc-', 'fakezig', '843419']; + assert.ok(residueNeedles.length > 0, + 'S22 non-vacuity: residue needle list must be non-empty'); + }); + +});