diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index a1bd5b9f..dbe4c9ef 100644 --- a/.devflow/features/release-pipeline/KNOWLEDGE.md +++ b/.devflow/features/release-pipeline/KNOWLEDGE.md @@ -36,7 +36,7 @@ pull_request ─┘ (publish jobs tag-guarded / input-gu Three entry points, one workflow: -- **`push.tags v*`** — full coordinated release; all ten jobs run. +- **`push.tags v*`** — full coordinated release; all eleven jobs run. - **`workflow_dispatch`** — dry run; five publish jobs are `skipped` (tag guard fails). Add `-f testpypi=true` to also trigger the `publish-testpypi` opt-in leg. - **`pull_request` (path-filtered)** — rehearsal only; paths filter is the release surface @@ -47,12 +47,12 @@ Three entry points, one workflow: (avoids PF-017's cancelled-run-as-green shape mid-sequence and prevents a partial-publish state between `cargo publish` and `npm publish`). -## Component Architecture — Ten Jobs and Their DAG +## Component Architecture — Eleven Jobs and Their DAG ``` version-gate - ├─→ build-napi (7 legs) ─→ stage-and-verify-napi ─┐ - └─→ build-python (8 legs) ─→ rehearse-publish-python ─┤ + ├─→ build-napi (7 legs) ─→ stage-and-verify-napi ─→ load-test-musl-arm64 ─┐ + └─→ build-python (8 legs) ─→ rehearse-publish-python ──────────────────────┤ └─→ publish-testpypi │ (dispatch+input only) ↓ publish-crates (tag only) @@ -70,14 +70,22 @@ Job details: | `version-gate` | Version gate | nothing (always runs) | — | | `build-napi` | Build napi (...) | nothing | version-gate | | `stage-and-verify-napi` | Stage + verify platform packages | nothing | build-napi | +| `load-test-musl-arm64` | Alpine load test (linux-arm64-musl) | unguarded, not-cancelled + `needs.stage-and-verify-napi.result == 'success'` | stage-and-verify-napi | | `build-python` | Build Python (...) | nothing | version-gate | | `rehearse-publish-python` | Rehearse PyPI publish (no upload) | nothing | build-python | | `publish-testpypi` | Publish to TestPyPI (rehearsal) | `workflow_dispatch && inputs.testpypi` | build-python, rehearse-publish-python | -| `publish-crates` | Publish to crates.io | `startsWith(ref, 'refs/tags/v')` | version-gate, stage-and-verify-napi, build-python, rehearse-publish-python | +| `publish-crates` | Publish to crates.io | `startsWith(ref, 'refs/tags/v')` | version-gate, stage-and-verify-napi, load-test-musl-arm64, build-python, rehearse-publish-python | | `publish-npm` | Publish to npm | `startsWith(ref, 'refs/tags/v')` | stage-and-verify-napi, publish-crates | | `publish-python` | Publish to PyPI | `startsWith(ref, 'refs/tags/v')` | build-python, rehearse-publish-python, publish-crates, publish-npm | | `github-release` | GitHub Release | `startsWith(ref, 'refs/tags/v')` | publish-crates, publish-npm, publish-python | +`load-test-musl-arm64` proves the `linux-arm64-musl` addon dlopens on `node:22-alpine` +(the readelf gate proves ELF metadata but not runtime loadability — a missing NEEDED +entry like `libunwind.so.1` is invisible to readelf; PF-038 shape). The x64 equivalent +runs as the last step of `stage-and-verify-napi` after the staged artifact upload, so +an x64 failure never suppresses the artifact; `load-test-musl-arm64` is skipped when +x64 fails (both are re-run together after the fix). + Key ordering constraints: - `publish-crates` blocks on `rehearse-publish-python`: a failed OIDC exchange aborts before the irreversible crates.io write (PF-039). - `publish-crates` blocks on `build-python`: a Python wheel build failure aborts before ANY registry write (PF-023). @@ -86,7 +94,7 @@ Key ordering constraints: ## Component Interactions — RELEASE_SURFACE and the Three-Place Rule (ADR-013) -The `pull_request` trigger fires on exactly five paths (the **release surface**): +The `pull_request` trigger fires on exactly six paths (the **release surface**): ``` .github/workflows/release.yml @@ -94,11 +102,12 @@ The `pull_request` trigger fires on exactly five paths (the **release surface**) crates/mds-napi/** crates/mds-python/** scripts/verify-napi-names.mjs +scripts/musl-load-probe.cjs ``` `crates/mds-core/**`, `Cargo.toml`, and `package.json` are excluded on purpose: they change on most PRs and `ci.yml` already covers them. A dependency sweep that does not touch these -five paths still needs a manual `workflow_dispatch` dry run. +six paths still needs a manual `workflow_dispatch` dry run. `scripts/verify-pr-checks.mjs` exports `RELEASE_SURFACE` (the same list) and spec S10 in `release-auth-probe.spec.mjs` asserts set-equality between the two. They must be kept in @@ -154,6 +163,12 @@ the verifier additionally requires `Version gate`, `Stage + verify platform pack by suite — the check-run must belong to a `release.yml` run; any event counts. All runs under each name must pass (duplicate names = all-must-pass). +`Alpine load test (linux-arm64-musl)` (`load-test-musl-arm64`) is a Tier-B-binding +unguarded check-run on release-surface PRs — it reaches `conclusion=success` on every PR +and dispatch run. It is deliberately NOT listed in `RELEASE_SURFACE_CONTEXTS` (the 2026-09 +verifier fixtures predate it); spec S21 pins its existence, runner, guard shape, wiring, +and step order instead (ADR-013). It is not a branch-protection required context. + The verifier prints `gh pr merge N --squash --admin --match-head-commit ` on PASS. Always use this command verbatim — confirm the current branch resolves to the intended PR before running it (the command names a SHA but not a PR; `gh pr merge` re-resolves the @@ -173,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, 210 tests including pin-shape specs (S16) and per-leg cache key spec (S20). +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). 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 @@ -280,6 +295,23 @@ such a file. Never write `${{` in comments; describe it in words. host-built build scripts (PF-041). Confirmed live in run 34065573775: every Linux leg in `build-napi` restored `v0-rust-build-napi-Linux-x64-6ff13d87-4c33221b`. Spec S20 in `release-auth-probe.spec.mjs` fails `Version gate` if a key is removed (#347, #352). +- **Using `job-level container:` on an arm64 runner for Alpine load tests**: GitHub-hosted + arm64 runners reject `container:` at job level with "JavaScript Actions in Alpine + containers are only supported on x64 Linux runners". Use `docker run` from a host job + instead. +- **Adding a `needs:` edge to `publish-crates` without a matching `result == 'success'` + conjunct in its `if:`** (PF-047): the `!cancelled()` opener removes the implicit + success-of-needs gate, so a failed load-test job would not block crates.io publish; + both `needs:` AND `if:` must name the dependency. +- **Counting `positive control` occurrences without `stripCommentLines` first**: the + `build-python` banner comment contains the phrase `positive control`; stripping comment + lines before counting is required to get the correct count. +- **Building a load-test fixture from the crate directory or the artifact root**: `napi + artifacts --output-dir .` writes every `.node` into the crate root; the artifact also carries + root-level `*.node` files — either source activates candidate 2 of the loader (`.node` + 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. ## Gotchas @@ -320,18 +352,44 @@ such a file. Never write `${{` in comments; describe it in words. in `version-gate` is therefore the strongest check available. A revoked token is first detected at `cargo publish` (fail-before-write, after the build matrix is paid for); see PF-023 and the v0.4.0 precedent (`gh run rerun --failed`). Durable fix tracked in #368. +- **`napi-staged` artifact carries root-level `*.node` files** in addition to `npm/**`: the + artifact upload captures the crate root, which may contain multiple addon vintages from + prior `build:native` or `napi build --platform` calls. Never place a `.node` file beside + `index.js` in a load-test fixture — loader candidate 2 would short-circuit and bypass + the platform-package lookup. +- **`extractNeeds` is comment-stripped**: the parser strips comment lines from the job + section before matching `needs: [...]` so `# needs: [bogus]` is ignored. A comment above + an inline `needs:` line would previously confuse parsers that did not strip first. +- **`/usr/bin/ldd` on `node:22-alpine` is musl-utils' script**: the file is a shell script + containing the literal string `musl`; `readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')` + is the `isMusl()` predicate both in `index.js` and in `musl-load-probe.cjs`. +- **Docker Hub pull limit exemption for hosted runners**: GitHub-hosted runners are exempt + from Docker Hub's anonymous pull limit for public images (documented at + docs.github.com/en/actions/reference/limits). A mirror (`public.ecr.aws`) would operate + under a tighter tier. The gate uses Docker Hub directly and is blocking. +- **Alpine container must run with `-w /w`**: `node:22-alpine` sets no `WORKDIR`; the default + container cwd is `/`; mds-core rejects a filesystem-root base directory with "cannot resolve + path /: file not found: /" (#371, surfaced by this gate's first run on PR #370). All four + `docker run` invocations in the Alpine load-test steps pass `-w /w` so the probe executes + 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. ## Key Files -- `.github/workflows/release.yml` — the complete 10-job release workflow (1260+ lines). +- `.github/workflows/release.yml` — the complete 11-job release workflow (1430+ lines). - `.github/workflows/ci.yml` — the build/test workflow whose contexts populate Tier A. - `scripts/verify-pr-checks.mjs` — mandatory pre-merge verifier; exports `EXPECTED_CONTEXTS`, `TIER_B_EXPECTED_SKIPPED`, `RELEASE_SURFACE`, `RELEASE_SURFACE_CONTEXTS`. +- `scripts/musl-load-probe.cjs` — Alpine container smoke-test for musl napi addons; accepts + `linux-x64-musl` or `linux-arm64-musl` as argv[2]; run inside `node:22-alpine` via + `docker run --rm --network none --pull=never -w /w -v :/w:ro node /w/probe.cjs `. - `scripts/__test__/verify-pr-checks.spec.mjs` — specs for the verifier (M10c, S13, S18 rules; length assertion for `EXPECTED_CONTEXTS`). - `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). + (S14), per-leg cache key (S20), cargo token -z guard (S3 extension), Alpine load test + job structure and wiring (S21). - `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 2282b87e..a536590e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ name: Release # scripts/verify-pr-checks.mjs for exactly the five enumerated job names in # TIER_B_EXPECTED_SKIPPED). crates/mds-core/**, Cargo.toml, and package.json # are excluded on purpose: they change on most PRs and ci.yml already covers -# them; a dependency sweep that does not touch the five paths below still needs +# them; a dependency sweep that does not touch the six paths below still needs # the manual workflow_dispatch dry-run. on: push: @@ -24,6 +24,7 @@ on: - 'crates/mds-napi/**' - 'crates/mds-python/**' - 'scripts/verify-napi-names.mjs' + - 'scripts/musl-load-probe.cjs' workflow_dispatch: inputs: testpypi: @@ -449,6 +450,171 @@ jobs: path: | crates/mds-napi/npm/** crates/mds-napi/*.node + if-no-files-found: error + # readelf proves ELF metadata, not dlopen, so it is insufficient to confirm + # the addon loads on Alpine musl (PF-038). The fixture shape IS the assertion + # (PF-013): index.js + probe + ONLY the musl platform package, no .node beside + # index.js (loader candidate 2 would short-circuit), gnu package absent — so + # a pass is only possible if the loader's isMusl() returned true. This step + # never runs from crates/mds-napi/ because napi artifacts writes every .node + # into the crate root; the staged npm/ tree is the fixture source. + # This step has no local counterpart (PF-036). publish-crates blocks on it. + - name: "Alpine load test (linux-x64-musl)" + shell: bash + env: + ALPINE_IMAGE: node:22-alpine + PLATFORM: linux-x64-musl + ARCHKEY: linux-x64 + NPM_DIR: crates/mds-napi/npm + run: | + set -euo pipefail + shopt -s nullglob + FIX="${RUNNER_TEMP}/alpine-load-${PLATFORM}"; CTRL="${FIX}-control" + rm -rf "${FIX}" "${CTRL}"; mkdir -p "${FIX}/node_modules/@mdscript" + cp crates/mds-napi/index.js "${FIX}/index.js" + cp scripts/musl-load-probe.cjs "${FIX}/probe.cjs" + cp -R "${NPM_DIR}/${PLATFORM}" "${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}" + # Fixture shape IS the assertion (PF-013): index.js + probe + ONE platform package. + bin="${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}/mds-napi.${PLATFORM}.node" + [ -s "${bin}" ] || { echo "::error::staged package has no ${bin##*/} (did napi artifacts skip this leg?)"; exit 1; } + stray=("${FIX}"/*.node) + [ "${#stray[@]}" -eq 0 ] || { echo "::error::.node beside index.js: ${stray[*]} (loader candidate 2 would short-circuit the package path)"; exit 1; } + [ ! -e "${FIX}/node_modules/@mdscript/mds-napi-${ARCHKEY}-gnu" ] || { echo "::error::gnu package present in the fixture; a false isMusl() could pass"; exit 1; } + scopes=("${FIX}"/node_modules/*); pkgs=("${FIX}"/node_modules/@mdscript/*) + { [ "${#scopes[@]}" -eq 1 ] && [ "${#pkgs[@]}" -eq 1 ]; } || { echo "::error::fixture must hold exactly one package (scopes=${#scopes[@]} packages=${#pkgs[@]})"; exit 1; } + cp -R "${FIX}" "${CTRL}"; rm -rf "${CTRL}/node_modules" + # GitHub-hosted runners are exempt from Docker Hub's pull limit for public images; a mirror + # would trade a documented exemption for a tighter quota (#340). Bounded retry (PF-013 shape). + PULLED=0 + for i in 1 2 3; do + if timeout 300 docker pull --quiet "${ALPINE_IMAGE}"; then PULLED=1; break; fi + echo "::notice::docker pull attempt ${i}/3 failed for ${ALPINE_IMAGE}; retrying in 10 s"; sleep 10 + done + [ "${PULLED}" -eq 1 ] || { echo "::error::docker pull failed for ${ALPINE_IMAGE} after 3 attempts"; exit 1; } + # Go template braces on the next line, not an Actions expression (Actions interpolates only + # the dollar form), same as the rehearsal job's digest print. + docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' + # node:22-alpine sets no WORKDIR; the default cwd is /; mds-core rejects a filesystem-root + # base directory (#371, found by this gate's first run); the workdir flag (-w) is set to /w + # (fixture dir, the shape any real non-root cwd has); probe asserts cwd; dropped flag exits 1. + # Positive control FIRST (PF-013): without the package the SAME probe must FAIL, and it must + # fail on loader candidate 3 for the MUSL KEY - that failure is also the isMusl() proof. + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \ + -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + echo "::error::positive control FAILED - the probe passed WITHOUT the ${PLATFORM} package, so a green run proves nothing (PF-013)" + printf '%s\n' "${CTRL_LOG}"; exit 1 + fi + printf '%s\n' "${CTRL_LOG}" + for needle in 'Failed to load mds-napi native binding for' "mds-napi.${PLATFORM}.node" "@mdscript/mds-napi-${PLATFORM}"; do + printf '%s\n' "${CTRL_LOG}" | grep -qF -- "${needle}" || { echo "::error::control failed for the wrong reason: missing '${needle}'"; exit 1; } + done + if printf '%s\n' "${CTRL_LOG}" | grep -q -- "${ARCHKEY}-gnu"; then + echo "::error::the loader computed the gnu key on Alpine - isMusl() returned false"; exit 1 + fi + echo "positive control OK: load failed without the musl package, on the musl key" + timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \ + -v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" + + # --------------------------------------------------------------------------- + # Alpine musl load test on native arm64 — docker run from host job (ADR-013). + # Public repos get free native ubuntu-24.04-arm runners (no ubuntu-latest-arm + # label). Job-level container: is rejected by the runner on arm64: "JavaScript + # Actions in Alpine containers are only supported on x64 Linux runners". QEMU + # emulation is disqualified on reliability: Node segfaults under arm64 QEMU + # emulation (actions/runner-images#11471). Test is docker run from the host. + # This job is unguarded BY DESIGN (ADR-013): it reaches success on every PR and + # dispatch run, so it is NOT in TIER_B_EXPECTED_SKIPPED (M10c keeps 5 guarded + # jobs) and deliberately NOT in RELEASE_SURFACE_CONTEXTS. Spec S21 pins it. + # publish-crates lists this job in both its needs and its if-conjuncts (PF-047). + # --------------------------------------------------------------------------- + load-test-musl-arm64: + name: Alpine load test (linux-arm64-musl) + needs: [stage-and-verify-napi] + if: ${{ !cancelled() && needs.stage-and-verify-napi.result == 'success' }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Assert native arm64 runner + run: | + [ "$(uname -m)" = aarch64 ] || { echo "::error::expected an aarch64 runner (label ubuntu-24.04-arm), got $(uname -m)"; exit 1; } + docker version --format 'docker server {{.Server.Version}}' + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + - name: Restore staged napi tree + uses: actions/download-artifact@v8 + with: + name: napi-staged + path: staged + - name: Assert the staged tree is complete and the arm64 binary is AArch64 + run: | + set -euo pipefail + n=$(find staged/npm -mindepth 1 -maxdepth 1 -type d | wc -l) + [ "$n" -eq 7 ] || { echo "::error::expected 7 platform dirs in staged/npm, found $n"; exit 1; } + arm=staged/npm/linux-arm64-musl/mds-napi.linux-arm64-musl.node + x64=staged/npm/linux-x64-musl/mds-napi.linux-x64-musl.node + { [ -s "$arm" ] && [ -s "$x64" ]; } || { echo "::error::musl binaries missing from the staged tree"; exit 1; } + # Positive control (PF-013): the x86_64 sibling must NOT read as AArch64. + if readelf -h "$x64" | grep -q 'Machine:.*AArch64'; then echo "::error::positive control FAILED - x86_64 binary read as AArch64"; exit 1; fi + echo "positive control OK: the x86_64 sibling is not AArch64" + readelf -h "$arm" | grep -q 'Machine:.*AArch64' || { echo "::error::$arm is not an AArch64 ELF"; readelf -h "$arm"; exit 1; } + - name: "Alpine load test (linux-arm64-musl)" + shell: bash + env: + ALPINE_IMAGE: node:22-alpine + PLATFORM: linux-arm64-musl + ARCHKEY: linux-arm64 + NPM_DIR: staged/npm + run: | + set -euo pipefail + shopt -s nullglob + FIX="${RUNNER_TEMP}/alpine-load-${PLATFORM}"; CTRL="${FIX}-control" + rm -rf "${FIX}" "${CTRL}"; mkdir -p "${FIX}/node_modules/@mdscript" + cp crates/mds-napi/index.js "${FIX}/index.js" + cp scripts/musl-load-probe.cjs "${FIX}/probe.cjs" + cp -R "${NPM_DIR}/${PLATFORM}" "${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}" + # Fixture shape IS the assertion (PF-013): index.js + probe + ONE platform package. + bin="${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}/mds-napi.${PLATFORM}.node" + [ -s "${bin}" ] || { echo "::error::staged package has no ${bin##*/} (did napi artifacts skip this leg?)"; exit 1; } + stray=("${FIX}"/*.node) + [ "${#stray[@]}" -eq 0 ] || { echo "::error::.node beside index.js: ${stray[*]} (loader candidate 2 would short-circuit the package path)"; exit 1; } + [ ! -e "${FIX}/node_modules/@mdscript/mds-napi-${ARCHKEY}-gnu" ] || { echo "::error::gnu package present in the fixture; a false isMusl() could pass"; exit 1; } + scopes=("${FIX}"/node_modules/*); pkgs=("${FIX}"/node_modules/@mdscript/*) + { [ "${#scopes[@]}" -eq 1 ] && [ "${#pkgs[@]}" -eq 1 ]; } || { echo "::error::fixture must hold exactly one package (scopes=${#scopes[@]} packages=${#pkgs[@]})"; exit 1; } + cp -R "${FIX}" "${CTRL}"; rm -rf "${CTRL}/node_modules" + # GitHub-hosted runners are exempt from Docker Hub's pull limit for public images; a mirror + # would trade a documented exemption for a tighter quota (#340). Bounded retry (PF-013 shape). + PULLED=0 + for i in 1 2 3; do + if timeout 300 docker pull --quiet "${ALPINE_IMAGE}"; then PULLED=1; break; fi + echo "::notice::docker pull attempt ${i}/3 failed for ${ALPINE_IMAGE}; retrying in 10 s"; sleep 10 + done + [ "${PULLED}" -eq 1 ] || { echo "::error::docker pull failed for ${ALPINE_IMAGE} after 3 attempts"; exit 1; } + # Go template braces on the next line, not an Actions expression (Actions interpolates only + # the dollar form), same as the rehearsal job's digest print. + docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' + # node:22-alpine sets no WORKDIR; the default cwd is /; mds-core rejects a filesystem-root + # base directory (#371, found by this gate's first run); the workdir flag (-w) is set to /w + # (fixture dir, the shape any real non-root cwd has); probe asserts cwd; dropped flag exits 1. + # Positive control FIRST (PF-013): without the package the SAME probe must FAIL, and it must + # fail on loader candidate 3 for the MUSL KEY - that failure is also the isMusl() proof. + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \ + -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + echo "::error::positive control FAILED - the probe passed WITHOUT the ${PLATFORM} package, so a green run proves nothing (PF-013)" + printf '%s\n' "${CTRL_LOG}"; exit 1 + fi + printf '%s\n' "${CTRL_LOG}" + for needle in 'Failed to load mds-napi native binding for' "mds-napi.${PLATFORM}.node" "@mdscript/mds-napi-${PLATFORM}"; do + printf '%s\n' "${CTRL_LOG}" | grep -qF -- "${needle}" || { echo "::error::control failed for the wrong reason: missing '${needle}'"; exit 1; } + done + if printf '%s\n' "${CTRL_LOG}" | grep -q -- "${ARCHKEY}-gnu"; then + echo "::error::the loader computed the gnu key on Alpine - isMusl() returned false"; exit 1 + fi + echo "positive control OK: load failed without the musl package, on the musl key" + timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \ + -v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" # --------------------------------------------------------------------------- # Python wheels + sdist — cp311-abi3, 7 platforms + sdist. @@ -991,8 +1157,15 @@ jobs: # rehearse-publish-python is listed here so a failed OIDC exchange aborts # before the irreversible crates.io write (if PyPI will fail, we learn before # crates.io is already live at the new version). - needs: [version-gate, stage-and-verify-napi, build-python, rehearse-publish-python] - if: ${{ !cancelled() && needs.version-gate.result == 'success' && needs.stage-and-verify-napi.result == 'success' && needs.build-python.result == 'success' && needs.rehearse-publish-python.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} + # load-test-musl-arm64 is listed here so the Alpine dlopen test on the + # native arm64 runner must pass before the irreversible crates.io publish. + # Because the condition starts with !cancelled(), the implicit + # success-of-dependencies gate is gone and the needs list is ordering-only; + # every dependency's result must be enumerated as an if-conjunct. A needs + # entry without its conjunct would let a failed load test reach cargo publish, + # which is irreversible (PF-023, PF-047). + needs: [version-gate, stage-and-verify-napi, load-test-musl-arm64, build-python, rehearse-publish-python] + if: ${{ !cancelled() && needs.version-gate.result == 'success' && needs.stage-and-verify-napi.result == 'success' && needs.load-test-musl-arm64.result == 'success' && needs.build-python.result == 'success' && needs.rehearse-publish-python.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5f6176..b854d0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds an unguarded `rehearse-publish-python` job (PF-039) that rehearses `publish-python` without uploading — pin shape (both the annotated-tag-object SHA a892a5a6 and the commit SHA dc37677b are rejected as positive controls), GHCR manifest (404 + `MANIFEST_UNKNOWN` body required), bounded `docker pull` (3-attempt loop), and `twine check` from the pinned image with `--network none`, each with a positive control (PF-013, PF-040) — plus a `publish-testpypi` opt-in leg (dispatch-guarded); the rehearsal never invokes `pypa/gh-action-pypi-publish` (the action has no dry-run mode) and is denied `id-token`, both pinned by specs S14/S15; credential and OIDC probes run on `pull_request` events and fail closed on fork/Dependabot PRs (no secrets, no `id-token: write`) with an actionable error; `verify-pr-checks.mjs` requires `Version gate`, `Stage + verify platform packages` and `Rehearse PyPI publish (no upload)` on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350). - `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. ## [0.4.2] — 2026-09-03 diff --git a/RELEASING.md b/RELEASING.md index fd31c77a..1a2197d1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -202,15 +202,15 @@ PR or dispatch by hand. Even though release-surface PRs now trigger `release.yml` automatically, a manual `gh workflow run release.yml --ref ` is still required in four -cases (the five release-surface paths are `.github/workflows/release.yml`, -`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and -`scripts/verify-napi-names.mjs`): +cases (the six release-surface paths are `.github/workflows/release.yml`, +`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, +`scripts/verify-napi-names.mjs`, and `scripts/musl-load-probe.cjs`): 1. **CI-history gate (PF-017)** — the gate is step-skipped on `pull_request` because `github.sha` is the ephemeral merge commit, not the branch head; a `::notice::` makes the skip visible. It runs only on tag push and dispatch. 2. **Changes outside the release surface** — dependency sweeps, - `crates/mds-core/**`, `Cargo.toml`, and `package.json` are not in the five + `crates/mds-core/**`, `Cargo.toml`, and `package.json` are not in the six paths above and do not trigger a `pull_request` run on `release.yml`. 3. **Dependabot and fork PRs** — no repository secrets and no `id-token: write`; `Version gate` fails closed with the "No Actions secrets on this run" error. @@ -285,11 +285,11 @@ runtime on the affected platform. Do not proceed past a failing gate. ### Release-surface PRs Release-surface PRs — those touching `.github/workflows/release.yml`, -`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, or -`scripts/verify-napi-names.mjs` — also trigger the workflow via the -`pull_request` event, so a Dependabot bump to an action reachable only from a -tag-guarded job is exercised on the PR instead of first running on a tag push -after crates.io has published (PF-039). +`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, +`scripts/verify-napi-names.mjs`, or `scripts/musl-load-probe.cjs` — also +trigger the workflow via the `pull_request` event, so a Dependabot bump to an +action reachable only from a tag-guarded job is exercised on the PR instead of +first running on a tag push after crates.io has published (PF-039). On such PRs, `verify-pr-checks.mjs` requires three additional check-runs: `Version gate`, `Stage + verify platform packages`, and `Rehearse PyPI publish @@ -307,6 +307,16 @@ check suite (D-PR8, #341 — any release.yml event counts, including `pull_reque and `workflow_dispatch`); the verifier exits 2 when it cannot enumerate the head's workflow runs. +The `Alpine load test (linux-arm64-musl)` job (`load-test-musl-arm64`) is an +unguarded Tier-B-binding check-run on release-surface PRs — it reaches +`conclusion=success` on every PR and dispatch run. It is deliberately NOT added +to `RELEASE_SURFACE_CONTEXTS` (the 2026-09 verifier fixtures predate it and +would fail if it appeared there); instead, spec S21 in +`scripts/__test__/release-auth-probe.spec.mjs` pins its existence, runner, +guard shape, wiring into `publish-crates`, and step order. It is not a +branch-protection required context (branch protection covers `ci.yml` jobs only; +`release.yml` jobs only run on release-surface PRs). + ## Release ### Tag-push (the only path) @@ -350,18 +360,32 @@ The `release.yml` workflow runs, in order: 3. **build-python** (parallel with build-napi) — builds `cp311-abi3` wheels for 7 platforms + sdist, runs the readelf linkage gate on Linux legs. 4. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies - LICENSE into each platform dir, runs the **A3 name-gate**. - 5. **rehearse-publish-python** — pin shape, GHCR manifest, `docker pull` and + LICENSE into each platform dir, runs the **A3 name-gate**. The last step + runs the x64 Alpine load test (`linux-x64-musl`) inside `node:22-alpine` + after the staged artifact upload, so the artifact is preserved even when + the x64 test fails; if x64 fails, the arm64 job is skipped and both are + re-run together after the fix. + 5. **load-test-musl-arm64** — unguarded job on a native `ubuntu-24.04-arm` + runner (no QEMU); downloads the `napi-staged` artifact; asserts the arm64 + ELF shape with a positive control; runs the arm64 Alpine load test + (`linux-arm64-musl`) on `node:22-alpine` with a run block byte-identical + to the x64 step. Skipped when x64 fails (`if: !cancelled() && + needs.stage-and-verify-napi.result == 'success'`); both are re-run + together after a fix. `publish-crates` needs this job and requires + `needs.load-test-musl-arm64.result == 'success'` in its `if:` (PF-047, + PF-038, #340). + 6. **rehearse-publish-python** — pin shape, GHCR manifest, `docker pull` and `twine check` (each with a positive control); uploads nothing and holds no OIDC token. publish-crates blocks on this so a broken action pin aborts before crates.io (irreversible). - 6. **publish-crates** — blocked until `stage-and-verify-napi`, `build-python`, - AND `rehearse-publish-python` succeed. `cargo publish` `mds-core`, polls the - crates.io index for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`. - 7. **publish-npm** and **publish-python** (parallel, both after publish-crates) + 7. **publish-crates** — blocked until `stage-and-verify-napi`, + `load-test-musl-arm64`, `build-python`, AND `rehearse-publish-python` + succeed. `cargo publish` `mds-core`, polls the crates.io index for up to + 5 min (bounded, max 20 × 15 s), then `mds-cli`. + 8. **publish-npm** and **publish-python** (parallel, both after publish-crates) — publish npm packages (with provenance) and PyPI `markdown-script` (OIDC trusted publishing + PEP 740 attestations, `skip-existing: true`). - 8. **github-release** — `gh release create` with generated notes; runs only + 9. **github-release** — `gh release create` with generated notes; runs only after all three publish jobs succeed. `publish-testpypi` never runs on a tag: it is guarded by `inputs.testpypi`, @@ -380,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. +- 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 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 f0c67541..ea16e98d 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -89,7 +89,7 @@ function findAllJobIds(source) { */ function extractNeeds(jobSection) { if (!jobSection) return []; - for (const line of jobSection.split('\n')) { + for (const line of stripCommentLines(jobSection).split('\n')) { // 4-space indent, inline array: ` needs: [a, b, c]` const m = /^\s+needs:\s+\[(.+)\]\s*$/.exec(line); if (m) return m[1].split(',').map(s => s.trim()); @@ -1321,3 +1321,598 @@ describe('B2: per-leg rust-cache keys in matrix jobs (#352, PF-041)', () => { }); }); + +// --------------------------------------------------------------------------- +// B3a: Alpine musl load-test helpers and S21 spec (#340) +// +// Two `docker run node:22-alpine` steps prove the musl addons actually dlopen +// on Alpine — x64 as the last step of stage-and-verify-napi, arm64 in a new +// unguarded job load-test-musl-arm64 on a native ubuntu-24.04-arm runner. +// The probe script is scripts/musl-load-probe.cjs. +// --------------------------------------------------------------------------- + +/** + * Extract the `runs-on:` value from a job section (4-space indent). + * Returns the trimmed value string, or null when absent. + */ +function runsOnOf(section) { + const m = /^ runs-on:\s+(.+)$/m.exec(section); + return m ? m[1].trim() : null; +} + +/** + * Return the `run: |` block text for the step whose segment contains + * `name: "Alpine load test (`. Segments steps exactly like `rustCacheSteps` + * (comment-stripped, `/^ - /` boundaries), finds the load-test step, and + * returns the lines after `run: |` that are indented deeper than the `run:` + * key, joined with '\n'. Returns null when no such step or run block is found. + * + * Trailing blank lines are dropped before joining. When the load-test step is + * the LAST step of its job, the scan runs to the end of the job section, so the + * blank separator lines between that step and the next job's comment banner + * (the banner itself is removed by stripCommentLines) would otherwise land + * inside the returned block. That would make the byte-equality assertion below + * sensitive to blank lines OUTSIDE either run block — a purely cosmetic edit to + * one job's spacing would fail S21 with "must be BYTE-EQUAL", a true verdict for + * a false reason. Blank lines are not shell code; only the script text is + * compared. Control PC-I pins both halves: trailing blanks are ignored, and a + * real trailing command difference is still detected. + */ +function loadTestRunBlock(section) { + const lines = stripCommentLines(section).split('\n'); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (/^ - /.test(lines[i])) starts.push(i); + } + for (const [n, start] of starts.entries()) { + const body = lines.slice(start, starts[n + 1] ?? lines.length); + if (!body.some(l => l.includes('name: "Alpine load test ('))) continue; + const runIdx = body.findIndex(l => /^\s+run: \|/.test(l)); + if (runIdx === -1) return null; + const runLineIndent = (body[runIdx].match(/^(\s*)/) ?? ['', ''])[1].length; + const runLines = []; + for (let i = runIdx + 1; i < body.length; i++) { + const line = body[i]; + if (line.trim() === '') { runLines.push(line); continue; } + const lineIndent = (line.match(/^(\s*)/) ?? ['', ''])[1].length; + if (lineIndent <= runLineIndent) break; + runLines.push(line); + } + while (runLines.length > 0 && runLines[runLines.length - 1].trim() === '') runLines.pop(); + return runLines.join('\n'); + } + return null; +} + +/** + * Return the 0-based index of the step (in the comment-stripped section) + * whose segment contains `needle`, or -1 when not found. + * Steps are segmented at `/^ - /` boundaries, matching `rustCacheSteps`. + */ +function stepIndexOf(section, needle) { + const lines = stripCommentLines(section).split('\n'); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (/^ - /.test(lines[i])) starts.push(i); + } + for (const [n, start] of starts.entries()) { + const body = lines.slice(start, starts[n + 1] ?? lines.length); + if (body.some(l => l.includes(needle))) return n; + } + return -1; +} + +/** + * Return the env: key-value lines for the Alpine load test step in the given + * section, comment-stripped, as "KEY: value" strings joined by '\n'. + * Returns null when the step or env block is absent. + */ +function loadTestEnvBlock(section) { + const lines = stripCommentLines(section).split('\n'); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (/^ - /.test(lines[i])) starts.push(i); + } + for (const [n, start] of starts.entries()) { + const body = lines.slice(start, starts[n + 1] ?? lines.length); + if (!body.some(l => l.includes('name: "Alpine load test ('))) continue; + const envIdx = body.findIndex(l => /^\s+env:\s*$/.test(l)); + if (envIdx === -1) return null; + const envIndent = (body[envIdx].match(/^(\s*)/) ?? ['', ''])[1].length; + const envLines = []; + for (let i = envIdx + 1; i < body.length; i++) { + const line = body[i]; + if (line.trim() === '') break; + const lineIndent = (line.match(/^(\s*)/) ?? ['', ''])[1].length; + if (lineIndent <= envIndent) break; + envLines.push(line.trim()); + } + return envLines.join('\n'); + } + return null; +} + +describe('B3a: Alpine musl load tests (#340)', () => { + + // ------------------------------------------------------------------------- + // S21: real Alpine dlopen tests for musl napi addons (#340). + // + // The release pipeline cross-compiles two musl addons (linux-x64-musl, + // linux-arm64-musl). The existing readelf gate proves ELF metadata but cannot + // prove the addon dlopens on Alpine. Two `docker run node:22-alpine` steps + // close this gap using scripts/musl-load-probe.cjs. + // + // Ordering invariant (PF-047): load-test-musl-arm64 must be in publish-crates + // needs: AND its result == 'success' must be in the if: conjunct. With + // !cancelled() present, needs: is ordering-only — only the if: conjunct gates. + // + // ADR-013 step-level-guard rule: the arm64 job must reach success on PRs so + // the mandatory verifier counts it. A job-level tag/input guard would make it + // skip, producing a skipped conclusion the verifier rejects. + // + // PF-013: parser controls run on planted YAML first; absence-only checks are + // vacuous. Failure message names #340, ADR-013, PF-013, PF-023. + // ------------------------------------------------------------------------- + test('S21: load-test-musl-arm64 job exists, is unguarded, wired into publish-crates, run blocks match (PF-047, ADR-013, #340)', () => { + + // ----------------------------------------------------------------------- + // Parser positive controls (PF-013) — all run against planted YAML strings, + // not the real release.yml. All must pass in both RED and GREEN states. + // ----------------------------------------------------------------------- + + // S21/PC-A: loadTestRunBlock finds a planted Alpine load test step and + // returns its run block content. Proves the helper does not always return null. + const plantedWithLoadTest = [ + ' fake-job:', + ' steps:', + ' - name: "Alpine load test (linux-x64-musl)"', + ' env:', + ' ALPINE_IMAGE: node:22-alpine', + ' run: |', + ' set -euo pipefail', + ' docker run --network none :/w:ro', + ' - name: Next step', + ' run: echo done', + ].join('\n'); + const plantedRunBlock = loadTestRunBlock(plantedWithLoadTest); + assert.ok( + plantedRunBlock !== null, + 'S21/PC-A: loadTestRunBlock must find a planted Alpine load test step (PF-013)', + ); + assert.ok( + plantedRunBlock.includes('set -euo pipefail'), + 'S21/PC-A: returned run block must include the planted run block content', + ); + + // S21/PC-B: "positive control" appearing ONLY in a comment yields zero + // matching lines after stripCommentLines. Proves the strip is effective. + const plantedCommentSection = [ + ' fake-job:', + ' steps:', + ' - name: fake step', + ' run: |', + ' # positive control: this line is a comment', + ' echo "all clear"', + ].join('\n'); + const pcLinesAfterStrip = stripCommentLines(plantedCommentSection) + .split('\n').filter(l => l.includes('positive control')); + assert.equal( + pcLinesAfterStrip.length, 0, + 'S21/PC-B: "positive control" in a comment only must yield zero matching lines ' + + 'after stripCommentLines (PF-013)', + ); + + // S21/PC-C: runsOnOf returns the correct value, and the equality check against + // the required runner fails for the wrong runner name. + const plantedArm64Section = ' fake-job:\n runs-on: ubuntu-24.04-arm64'; + assert.equal( + runsOnOf(plantedArm64Section), 'ubuntu-24.04-arm64', + 'S21/PC-C: runsOnOf must parse the runs-on value from a planted section', + ); + assert.notEqual( + runsOnOf(plantedArm64Section), 'ubuntu-24.04-arm', + 'S21/PC-C: ubuntu-24.04-arm64 must not equal ubuntu-24.04-arm (the required runner)', + ); + + // S21/PC-D: a needs-graph WITHOUT the publish-crates -> load-test-musl-arm64 + // edge makes transitivelyNeeds return false. Proves the edge is load-bearing + // and the check cannot be trivially satisfied. (PF-047) + const controlGraphMissingEdge = new Map([ + ['publish-crates', ['stage-and-verify-napi', 'version-gate']], + ['load-test-musl-arm64', ['stage-and-verify-napi']], + ['stage-and-verify-napi', ['build-napi']], + ['version-gate', []], + ['build-napi', ['version-gate']], + ]); + assert.ok( + !transitivelyNeeds(controlGraphMissingEdge, 'publish-crates', 'load-test-musl-arm64'), + 'S21/PC-D: a graph without the publish-crates -> load-test-musl-arm64 edge must ' + + 'report NOT transitive (PF-047, PF-013)', + ); + + // S21/PC-E: a publish-crates-shaped if: string lacking the load-test result + // conjunct is detectable. With !cancelled(), needs: is ordering-only — only + // the if: conjunct gates the job (PF-047). + const incompleteIf = + "${{ !cancelled() && needs.stage-and-verify-napi.result == 'success'" + + " && startsWith(github.ref, 'refs/tags/v') }}"; + assert.ok( + !incompleteIf.includes("needs.load-test-musl-arm64.result == 'success'"), + 'S21/PC-E: a publish-crates if: without the load-test result conjunct must be ' + + 'flagged as incomplete (PF-047)', + ); + + // S21/PC-F: two run blocks differing by exactly one character are unequal. + const runBlockA = 'set -euo pipefail\n echo "hello alpine"'; + const runBlockB = 'set -euo pipefail\n echo "hello alpinex"'; + assert.notEqual(runBlockA, runBlockB, + 'S21/PC-F: run blocks differing by one character must be unequal'); + + // S21/PC-G: a step text missing --network none is detectable; the same + // planted text also lacks timeout 600 docker run and -w /w (pin E2, #340, PF-013). + const missingNetwork = 'docker run --rm :/w:ro --pull=never alpine sh'; + assert.ok( + !missingNetwork.includes('--network none'), + 'S21/PC-G: a step text missing --network none must be detectable (PF-013)', + ); + assert.ok( + !missingNetwork.includes('timeout 600 docker run'), + 'S21/PC-G: a step text missing timeout 600 docker run must be detectable (PF-013, #340)', + ); + assert.ok( + !missingNetwork.includes('-w /w'), + 'S21/PC-G: a step text missing -w /w must be detectable — a root cwd trips the ' + + 'mds-core base-directory defect (#371, PF-013, #340)', + ); + + // S21/PC-H: extractNeeds strips comment lines before matching (hardening). + // A `# needs: [bogus]` comment above ` needs: [real-dep]` must yield + // ['real-dep'], not ['bogus']. (stripCommentLines call added to extractNeeds) + const commentedNeedsSection = [ + ' fake-job:', + ' # needs: [bogus]', + ' needs: [real-dep]', + ' steps:', + ].join('\n'); + assert.deepEqual( + extractNeeds(commentedNeedsSection), ['real-dep'], + 'S21/PC-H: extractNeeds must strip comment lines; # needs: [bogus] above ' + + 'needs: [real-dep] must yield [\'real-dep\'] (PF-013)', + ); + + // S21/PC-I: the byte-equality comparison below must ignore blank lines that + // sit OUTSIDE the run block — when the load-test step is the last step of a + // job, the scan reaches the end of the section and would otherwise absorb + // the blank separator before the next job's (comment-stripped) banner. Both + // halves are pinned so the trim cannot silently swallow a divergent script. + const plantLoadTestStep = (tail) => [ + ' fake-job:', + ' steps:', + ' - name: "Alpine load test (linux-x64-musl)"', + ' run: |', + ' set -euo pipefail', + ' docker run --network none :/w:ro', + ...tail, + ].join('\n'); + assert.equal( + loadTestRunBlock(plantLoadTestStep([])), + loadTestRunBlock(plantLoadTestStep(['', '', ''])), + 'S21/PC-I: two run blocks differing ONLY in trailing blank lines must compare ' + + 'EQUAL — a blank separator outside the block is not shell code, and letting it ' + + 'in makes S21 fail for a cosmetic edit to an unrelated job (PF-013)', + ); + assert.notEqual( + loadTestRunBlock(plantLoadTestStep([])), + loadTestRunBlock(plantLoadTestStep([' echo extra', ''])), + 'S21/PC-I: a run block carrying a real extra trailing COMMAND must still compare ' + + 'UNEQUAL — the trailing-blank trim must not swallow a divergent script (PF-013)', + ); + + // S21/PC-J: a section without 'Upload staged napi tree' yields stepIndexOf === -1, + // so the non-vacuity guard on the step-ordering check is demonstrably reachable — + // renaming that step cannot make the ordering check pass vacuously (PF-013, #340). + const sectionWithoutUpload = [ + ' fake-job:', + ' steps:', + ' - name: "Alpine load test (linux-x64-musl)"', + ' run: |', + ' echo hi', + ].join('\n'); + assert.strictEqual( + stepIndexOf(sectionWithoutUpload, 'name: Upload staged napi tree'), + -1, + 'S21/PC-J: stepIndexOf must return -1 when "Upload staged napi tree" is absent (PF-013, #340)', + ); + + // S21/PC-K: a planted upload step without if-no-files-found: error is flagged by + // the pin-E1 assertion below (the staged upload must fail loudly on an empty tree; #340). + const uploadStepWithoutIfNoFiles = [ + ' fake-job:', + ' steps:', + ' - name: Upload staged napi tree', + ' uses: actions/upload-artifact@v7', + ' with:', + ' name: napi-staged', + ].join('\n'); + assert.ok( + !uploadStepWithoutIfNoFiles.includes('if-no-files-found: error'), + 'S21/PC-K: a planted upload step without if-no-files-found: error must not include it (PF-013, #340)', + ); + + // S21/PC-K (cont.): a planted step whose if-no-files-found line is COMMENTED OUT + // must be rejected by stripCommentLines — proving the strip is what makes Pin-E1 + // non-bypassable by a commented-out line (#340, PF-013). + const commentedIfNoFiles = [ + ' fake-job:', + ' steps:', + ' - name: Upload staged napi tree', + ' uses: actions/upload-artifact@v7', + ' with:', + ' name: napi-staged', + ' # if-no-files-found: error', + ].join('\n'); + assert.ok( + commentedIfNoFiles.includes('if-no-files-found: error'), + 'S21/PC-K: planted step with commented if-no-files-found must include the raw text (PF-013, #340)', + ); + assert.ok( + !stripCommentLines(commentedIfNoFiles).includes('if-no-files-found: error'), + 'S21/PC-K: stripCommentLines must strip the commented if-no-files-found line, ' + + 'proving the pin is load-bearing (PF-013, #340)', + ); + + // S21/PC-L: a planted arm64-shaped load-test step with PLATFORM: linux-x64-musl is + // detectable — an arch flip would only fail at runtime (#340, PF-013). + const plantedArmWithWrongPlatform = [ + ' load-test-musl-arm64:', + ' steps:', + ' - name: "Alpine load test (linux-arm64-musl)"', + ' env:', + ' ALPINE_IMAGE: node:22-alpine', + ' PLATFORM: linux-x64-musl', + ' ARCHKEY: linux-arm64', + ' NPM_DIR: staged/npm', + ' run: |', + ' echo hi', + ].join('\n'); + const plantedArmEnv = loadTestEnvBlock(plantedArmWithWrongPlatform); + assert.ok( + plantedArmEnv !== null && !plantedArmEnv.includes('PLATFORM: linux-arm64-musl'), + 'S21/PC-L: a planted arm64 load-test step with PLATFORM: linux-x64-musl must not ' + + 'contain PLATFORM: linux-arm64-musl — demonstrating the per-arch env check is reachable (#340, PF-013)', + ); + + // ----------------------------------------------------------------------- + // Real-file assertions (S21) — these fail in the RED state because + // load-test-musl-arm64 does not exist in release.yml yet (#340, Phase A2). + // ----------------------------------------------------------------------- + + // S21: job must exist. Without it, linux-arm64-musl is never proven to dlopen + // on Alpine before an irreversible crates.io publish (PF-023, ADR-013). + const arm64JobSection = extractJobSection(yml, 'load-test-musl-arm64'); + assert.ok( + arm64JobSection !== null, + 'S21: load-test-musl-arm64 job must exist in release.yml. ' + + 'This unguarded job runs real Alpine load tests on a native ubuntu-24.04-arm ' + + 'runner (no QEMU) and must reach success on every PR/dispatch run (ADR-013). ' + + 'Without it, linux-arm64-musl addon is never proven to dlopen on Alpine before ' + + 'an irreversible crates.io publish (PF-023, #340). Phase A2 adds this job.', + ); + + assert.ok( + arm64JobSection.includes('name: Alpine load test (linux-arm64-musl)'), + 'S21: load-test-musl-arm64 must declare name: Alpine load test (linux-arm64-musl)', + ); + + // Must run on the native arm64 runner (no QEMU/cross-emulation). + assert.equal( + runsOnOf(arm64JobSection), 'ubuntu-24.04-arm', + 'S21: load-test-musl-arm64 must declare runs-on: ubuntu-24.04-arm (native arm64)', + ); + + // Must be unguarded at job level (ADR-013 step-level-guard rule: the job must + // reach success on every PR so the mandatory verifier can count it as success; + // a job-level tag/input guard makes it skip, which the Tier-B verifier rejects). + const arm64If = extractJobIf(arm64JobSection); + assert.ok( + arm64If === null || + (!arm64If.includes('refs/tags') && + !arm64If.includes('startsWith(github.ref') && + !arm64If.includes('inputs.')), + 'S21: load-test-musl-arm64 must not have a job-level if: guarded on refs/tags, ' + + 'startsWith(github.ref), or inputs. — a tag/input guard would skip the job on PRs, ' + + 'producing a skipped conclusion the Tier-B verifier rejects (ADR-013)', + ); + + // Must not use container: (x64-only for JS actions) or QEMU. + const arm64Stripped = stripCommentLines(arm64JobSection); + assert.ok( + !arm64Stripped.split('\n').some(l => /^ container:/.test(l)), + 'S21: load-test-musl-arm64 must not declare a job-level container: ' + + '(container: is x64-only for JS actions; use docker run from the host job)', + ); + assert.ok(!arm64Stripped.includes('setup-qemu'), + 'S21: load-test-musl-arm64 must not use setup-qemu (native runner eliminates QEMU)'); + assert.ok(!arm64Stripped.includes('--platform'), + 'S21: load-test-musl-arm64 must not pass --platform to docker (native runner)'); + + // Required structural fields. + assert.ok(arm64JobSection.includes('timeout-minutes: 15'), + 'S21: load-test-musl-arm64 must declare timeout-minutes: 15'); + assert.ok(arm64JobSection.includes('contents: read'), + 'S21: load-test-musl-arm64 must declare permissions: contents: read'); + assert.ok(arm64JobSection.includes('uses: actions/checkout@'), + 'S21: load-test-musl-arm64 must include a checkout step'); + assert.ok(arm64JobSection.includes('name: napi-staged'), + 'S21: load-test-musl-arm64 must download the napi-staged artifact'); + + // Needs must be exactly [stage-and-verify-napi]. + assert.deepEqual( + extractNeeds(arm64JobSection), ['stage-and-verify-napi'], + 'S21: load-test-musl-arm64 needs must be exactly [stage-and-verify-napi]', + ); + + // --- Wiring (PF-047 guard) --- + // publish-crates must list load-test-musl-arm64 in BOTH needs: AND if:. + // With !cancelled(), needs: is ordering-only; only the if: conjunct gates + // the irreversible cargo publish (PF-047, PF-023). + + const publishCratesSection = extractJobSection(yml, 'publish-crates'); + assert.ok(publishCratesSection !== null, 'S21 non-vacuity: publish-crates must exist'); + + assert.ok( + extractNeeds(publishCratesSection).includes('load-test-musl-arm64'), + 'S21: publish-crates needs: must include load-test-musl-arm64 (PF-047, #340)', + ); + + assert.ok( + transitivelyNeeds(buildNeedsGraph(yml), 'publish-crates', 'load-test-musl-arm64'), + 'S21: publish-crates must transitively need load-test-musl-arm64 (#340, PF-047)', + ); + + const publishCratesIf = extractJobIf(publishCratesSection); + assert.ok( + publishCratesIf !== null && + publishCratesIf.includes("needs.load-test-musl-arm64.result == 'success'"), + 'S21: publish-crates if: must include needs.load-test-musl-arm64.result == \'success\'. ' + + 'With !cancelled() present, needs: is ordering-only — the if: conjunct is the real gate ' + + 'preventing an irreversible cargo publish when the Alpine load test failed ' + + '(PF-047, PF-023, #340).', + ); + + // --- Step content checks --- + + const stageSection = extractJobSection(yml, 'stage-and-verify-napi'); + assert.ok(stageSection !== null, 'S21 non-vacuity: stage-and-verify-napi must exist'); + const stageStripped = stripCommentLines(stageSection); + + // The two job sections must be distinct strings (sanity check). + assert.notEqual(stageSection, arm64JobSection, + 'S21: stage-and-verify-napi and load-test-musl-arm64 sections must be distinct'); + + // Both must contain an Alpine load test step with a run block. + const stageRunBlock = loadTestRunBlock(stageSection); + const arm64RunBlock = loadTestRunBlock(arm64JobSection); + assert.ok(stageRunBlock !== null, + 'S21: stage-and-verify-napi must contain an Alpine load test step with run: |'); + assert.ok(arm64RunBlock !== null, + 'S21: load-test-musl-arm64 must contain an Alpine load test step with run: |'); + + // Run blocks must be BYTE-EQUAL — two divergent scripts create two failure modes. + assert.equal(stageRunBlock, arm64RunBlock, + 'S21: Alpine load test run blocks in stage-and-verify-napi and load-test-musl-arm64 ' + + 'must be BYTE-EQUAL (#340)'); + + // Validate required content in both run blocks (comment-stripped). + const needles = [ + '--network none', + ':/w:ro', + '--pull=never', + '-w /w', + 'timeout 300', + 'timeout 600 docker run', + 'probe.cjs', + 'NODE_PATH=', + ]; + // Non-vacuity: the needle list must be non-empty. + assert.ok(needles.length > 0, 'S21 non-vacuity: needle list must be non-empty'); + + for (const block of [stageRunBlock, arm64RunBlock]) { + const strippedBlock = stripCommentLines(block); + for (const needle of needles) { + assert.ok(strippedBlock.includes(needle), + `S21: Alpine load test run block must contain "${needle}" in executable code (#340)`); + } + // At least 2 executable lines containing "positive control" (PF-013: both a + // probe-level and a fixture-level control must be present). + const pcLines = strippedBlock.split('\n').filter(l => l.includes('positive control')); + assert.ok(pcLines.length >= 2, + `S21: Alpine load test run block must have at least 2 executable lines containing ` + + `"positive control" (PF-013); found ${pcLines.length}`); + // Safety: neither $PWD:/w nor GITHUB_WORKSPACE:/w (prevents host-path leakage). + assert.ok(!strippedBlock.includes('$PWD:/w'), + 'S21: Alpine load test run block must not use $PWD:/w'); + assert.ok(!strippedBlock.includes('GITHUB_WORKSPACE:/w'), + 'S21: Alpine load test run block must not use GITHUB_WORKSPACE:/w'); + } + + // Each load-test step env must include ALPINE_IMAGE: node:22-alpine. + assert.ok(stageSection.includes('ALPINE_IMAGE: node:22-alpine'), + 'S21: stage-and-verify-napi Alpine load test step env must include ALPINE_IMAGE: node:22-alpine'); + assert.ok(arm64JobSection.includes('ALPINE_IMAGE: node:22-alpine'), + 'S21: load-test-musl-arm64 step env must include ALPINE_IMAGE: node:22-alpine'); + + // Pin E1: the staged upload must fail loudly on an empty tree (#340). + assert.ok( + stageStripped.includes('if-no-files-found: error'), + 'S21 Pin E1: stage-and-verify-napi must contain if-no-files-found: error — ' + + 'the staged upload must fail loudly when the napi tree is empty (#340)', + ); + + // Per-arch env values — an arch flip would only fail at runtime (#340). + const stageEnv = loadTestEnvBlock(stageSection); + assert.ok(stageEnv !== null, + 'S21: stage-and-verify-napi Alpine load test step must have an env: block'); + assert.ok(stageEnv.includes('PLATFORM: linux-x64-musl'), + 'S21: stage-and-verify-napi load-test env must set PLATFORM: linux-x64-musl (#340)'); + assert.ok(stageEnv.includes('ARCHKEY: linux-x64'), + 'S21: stage-and-verify-napi load-test env must set ARCHKEY: linux-x64 (#340)'); + assert.ok(stageEnv.includes('NPM_DIR: crates/mds-napi/npm'), + 'S21: stage-and-verify-napi load-test env must set NPM_DIR: crates/mds-napi/npm (#340)'); + + const arm64Env = loadTestEnvBlock(arm64JobSection); + assert.ok(arm64Env !== null, + 'S21: load-test-musl-arm64 Alpine load test step must have an env: block'); + assert.ok(arm64Env.includes('PLATFORM: linux-arm64-musl'), + 'S21: load-test-musl-arm64 load-test env must set PLATFORM: linux-arm64-musl (#340)'); + assert.ok(arm64Env.includes('ARCHKEY: linux-arm64'), + 'S21: load-test-musl-arm64 load-test env must set ARCHKEY: linux-arm64 (#340)'); + assert.ok(arm64Env.includes('NPM_DIR: staged/npm'), + 'S21: load-test-musl-arm64 load-test env must set NPM_DIR: staged/npm (#340)'); + + // In stage-and-verify-napi, the load-test step must come AFTER Upload staged napi tree. + const uploadStepIdx = stepIndexOf(stageSection, 'name: Upload staged napi tree'); + const loadTestStepIdx = stepIndexOf(stageSection, 'name: "Alpine load test ('); + // Non-vacuity: both steps must exist; -1 > -1 is false but N > -1 holds for any N >= 0, + // making the ordering check vacuous when the upload step is renamed (PF-013, #340). + assert.ok( + uploadStepIdx !== -1 && loadTestStepIdx !== -1, + 'S21 non-vacuity: "Upload staged napi tree" and Alpine load test steps must both ' + + 'exist in stage-and-verify-napi (stepIndexOf returns -1 when absent; a missing ' + + 'upload step would let N > -1 pass vacuously; #340, PF-013)', + ); + // Exact-name check: stepIndexOf uses substring matching, so a suffix like " (v2)" + // would still return a non-(-1) index — this end-of-line regex catches any suffix rename + // (#340, PF-013). The YAML step line is " - name: Upload staged napi tree" so the + // regex anchors to EOL (no trailing chars after the name). + assert.ok( + /name: Upload staged napi tree\s*$/m.test(stageStripped), + 'S21 non-vacuity: stage-and-verify-napi must contain a step named exactly ' + + '"Upload staged napi tree" — a rename like (v2) bypasses the stepIndexOf check ' + + 'via substring matching but is caught here (#340, PF-013)', + ); + assert.ok( + loadTestStepIdx > uploadStepIdx, + `S21: in stage-and-verify-napi the Alpine load test step (index ${loadTestStepIdx}) ` + + `must come AFTER the Upload staged napi tree step (index ${uploadStepIdx}) so the ` + + 'artifact is available before the container mounts it (#340)', + ); + + // RELEASE_SURFACE must include scripts/musl-load-probe.cjs so that S10 forces + // it into on.pull_request.paths (ADR-013 three-place rule: probe changes must + // trigger the release rehearsal on PRs). + assert.ok( + RELEASE_SURFACE.includes('scripts/musl-load-probe.cjs'), + 'S21: RELEASE_SURFACE must include "scripts/musl-load-probe.cjs" so changes to the ' + + 'probe trigger release.yml on release-surface PRs (ADR-013, #340). Add the path to ' + + 'RELEASE_SURFACE in verify-pr-checks.mjs AND to on.pull_request.paths in release.yml.', + ); + + // Non-vacuity: job id must appear in the full job list. + assert.ok( + findAllJobIds(yml).includes('load-test-musl-arm64'), + 'S21 non-vacuity: load-test-musl-arm64 must appear in findAllJobIds output (#340)', + ); + }); + +}); diff --git a/scripts/musl-load-probe.cjs b/scripts/musl-load-probe.cjs new file mode 100644 index 00000000..50e41d8e --- /dev/null +++ b/scripts/musl-load-probe.cjs @@ -0,0 +1,143 @@ +// musl-load-probe.cjs — Alpine container smoke-test for musl napi addon load. +// #340, PF-013: the fixture shape IS the assertion; a pass is only possible if +// the correct musl platform package is mounted under /w/node_modules/ AND the +// loader's isMusl() returned true. Run inside `node:22-alpine` via: +// docker run --rm --network none --pull=never -w /w -v :/w:ro \ +// node /w/probe.cjs +// +// Steps: 1 argv, 2 cwd, 3 ldd/musl, 4 loader require, 5 path resolution, +// 6 exports, 7 compile smoke test. +// +// Fixture at /w: index.js (real loader), probe.cjs (this file), +// node_modules/@mdscript/mds-napi-/ (musl pkg only). +'use strict'; + +const VALID_PLATFORMS = ['linux-x64-musl', 'linux-arm64-musl']; +const platform = process.argv[2]; + +// Step 1: Validate argv — missing, extra, or unknown platform → exit 2. +if (process.argv.length !== 3 || !VALID_PLATFORMS.includes(platform)) { + process.stderr.write( + '::error::Usage: node probe.cjs \n' + + ' platform must be one of: ' + VALID_PLATFORMS.join(', ') + '\n' + + ' got: ' + JSON.stringify(process.argv.slice(2)) + '\n', + ); + process.exit(2); +} + +// Step 2: Assert cwd is /w — node:22-alpine sets no WORKDIR so the default cwd is /; +// mds-core rejects a filesystem-root base directory (#371, found by this gate's first +// run); the docker run must pass -w /w so this probe runs from the fixture dir. +if (process.cwd() !== '/w') { + process.stderr.write( + '::error::probe must run with cwd /w (docker run -w /w); got ' + + process.cwd() + ' — a root cwd trips the mds-core base-directory defect (#371)\n', + ); + process.exit(1); +} + +// Step 3: Verify musl via /usr/bin/ldd — re-implements isMusl() from index.js +// verbatim (readFileSync('/usr/bin/ldd','utf-8').includes('musl') inside try/catch). +// This check stays even though require() below also proves it — it makes the +// isMusl() predicate visible in the log (PF-013: absence-only check is vacuous). +const { readFileSync } = require('fs'); +let lddContent; +try { + lddContent = readFileSync('/usr/bin/ldd', 'utf-8'); +} catch (e) { + process.stderr.write('::error::Cannot read /usr/bin/ldd: ' + ((e && e.message) || String(e)) + '\n'); + process.exit(1); +} +if (!lddContent.includes('musl')) { + process.stderr.write( + '::error::isMusl() predicate failed: /usr/bin/ldd (' + + lddContent.length + ' chars) does not include "musl". ' + + 'This probe must run inside node:22-alpine, not a glibc container.\n', + ); + process.exit(1); +} +const lddBytes = Buffer.byteLength(lddContent, 'utf-8'); +process.stdout.write( + 'musl detected via /usr/bin/ldd (' + lddBytes + ' bytes), ' + + 'platform=' + process.platform + ' arch=' + process.arch + '\n', +); + +// Step 4: Load the real loader — never require the .node directly and never +// @mdscript/mds (its WASM fallback would make the test vacuous). +// Wrapped in try/catch so a load failure prints the full loader error message +// (including per-candidate details) via ::error:: before exiting, giving the +// three workflow needles their signal. +let b; +try { + b = require('/w/index.js'); +} catch (e) { + process.stderr.write('::error::require(\'/w/index.js\') failed: ' + ((e && e.message) || String(e)) + '\n'); + process.exit(1); +} + +// Step 5: Verify require.resolve path for the musl platform package. +// Must start with /w/node_modules/ and end with mds-napi..node, +// proving the loader used the fixture package, not a stale path or fallback. +const pkg = '@mdscript/mds-napi-' + platform; +let resolved; +try { + resolved = require.resolve(pkg); +} catch (e) { + process.stderr.write('::error::require.resolve(\'' + pkg + '\') failed: ' + ((e && e.message) || String(e)) + '\n'); + process.exit(1); +} +if (!resolved.startsWith('/w/node_modules/')) { + process.stderr.write( + '::error::resolved path for \'' + pkg + '\' must start with /w/node_modules/; ' + + 'got: ' + resolved + '\n', + ); + process.exit(1); +} +const expectedSuffix = 'mds-napi.' + platform + '.node'; +if (!resolved.endsWith(expectedSuffix)) { + process.stderr.write( + '::error::resolved path for \'' + pkg + '\' must end with \'' + expectedSuffix + '\'; ' + + 'got: ' + resolved + '\n', + ); + process.exit(1); +} +process.stdout.write('resolved ' + pkg + ' -> ' + resolved + '\n'); + +// Step 6: Verify exports are exactly the 7 required keys. +const EXPECTED_EXPORTS = 'check,checkFile,compile,compileFile,lint,lintFile,lintVirtual'; +const actualExports = Object.keys(b).sort().join(','); +if (actualExports !== EXPECTED_EXPORTS) { + process.stderr.write( + '::error::addon exports mismatch.\n' + + ' expected: ' + EXPECTED_EXPORTS + '\n' + + ' actual: ' + actualExports + '\n', + ); + process.exit(1); +} + +// Step 7: Compile smoke test. dlopen binds lazily so symbols resolve at CALL +// time — one export's type is not proof; we must call an exported function. +// Expected: { kind: 'markdown', output: 'Hello alpine!\n', ... } +let r; +try { + r = b.compile('Hello {{n}}!', { vars: { n: 'alpine' } }); +} catch (e) { + process.stderr.write('::error::compile() threw: ' + ((e && e.message) || String(e)) + '\n'); + process.exit(1); +} +if (r.kind !== 'markdown') { + process.stderr.write( + '::error::compile() returned kind=' + JSON.stringify(r.kind) + ', expected "markdown"\n', + ); + process.exit(1); +} +const EXPECTED_OUTPUT = 'Hello alpine!\n'; +if (r.output !== EXPECTED_OUTPUT) { + process.stderr.write( + '::error::compile() output mismatch.\n' + + ' expected: ' + JSON.stringify(EXPECTED_OUTPUT) + '\n' + + ' actual: ' + JSON.stringify(r.output) + '\n', + ); + process.exit(1); +} +process.stdout.write('load ok: compile(...) -> ' + JSON.stringify(r.output) + '\n'); diff --git a/scripts/verify-pr-checks.mjs b/scripts/verify-pr-checks.mjs index 64a05b82..82846821 100644 --- a/scripts/verify-pr-checks.mjs +++ b/scripts/verify-pr-checks.mjs @@ -208,6 +208,7 @@ export const RELEASE_SURFACE = [ 'crates/mds-napi/**', 'crates/mds-python/**', 'scripts/verify-napi-names.mjs', + 'scripts/musl-load-probe.cjs', ]; /**