Skip to content

ci(release): load-test both musl addons on node:22-alpine before publish (#340) - #370

Merged
dean0x merged 8 commits into
mainfrom
ci/b3a-alpine-load-tests
Sep 8, 2026
Merged

ci(release): load-test both musl addons on node:22-alpine before publish (#340)#370
dean0x merged 8 commits into
mainfrom
ci/b3a-alpine-load-tests

Conversation

@dean0x

@dean0x dean0x commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Summary

Both musl napi addons (linux-x64-musl, linux-arm64-musl) are now loaded for real on node:22-alpine before anything publishes, and publish-crates blocks on both results.

  • x64: a new last step Alpine load test (linux-x64-musl) in stage-and-verify-napi, placed after the napi-staged upload (which now has if-no-files-found: error).
  • arm64: a new unguarded job load-test-musl-arm64 (Alpine load test (linux-arm64-musl)) on a native ubuntu-24.04-arm runner, restoring the napi-staged artifact, asserting the staged tree is complete and the arm64 binary is AArch64 (with an x86_64 positive control), then running the byte-identical load-test step.
  • scripts/musl-load-probe.cjs (new): runs inside the container against a read-only fixture at /w; argv guard (exit 2), the loader's literal /usr/bin/ldd contains musl predicate, require('/w/index.js') (the real loader), require.resolve must land in /w/node_modules/…/mds-napi.<platform>.node, exactly the 7 exports, and compile('Hello {{n}}!', { vars: { n: 'alpine' } }) must return kind: markdown with output exactly "Hello alpine!\n" (dlopen binds lazily, so a call is required, not a typeof).
  • Spec S21 (scripts/__test__/release-auth-probe.spec.mjs, one test, twelve planted parser controls PC-A…PC-L) pins the job, its runner, its unguarded shape, the publish-crates wiring, the step order, per-arch env:, the upload's if-no-files-found: error, the docker bounds, and byte-equality of the two run blocks. extractNeeds is now comment-stripped.
  • scripts/musl-load-probe.cjs is a sixth release-surface path in both on.pull_request.paths and RELEASE_SURFACE (S10 set-equality). Docs: RELEASING.md, CHANGELOG, release-pipeline knowledge base.

What the first run found

The first PR release run (34274102158) proved the load path end to end — musl detected via /usr/bin/ldd (52 bytes), platform=linux arch=x64, the control fixture failed on the musl key with all three needles and no gnu key, and the package resolved to /w/node_modules/@mdscript/mds-napi-linux-x64-musl/mds-napi.linux-x64-musl.node — and then compile() threw cannot resolve path /: file not found: /. Diagnosis (reproduced locally on macOS with the darwin addon and with the CLI's stdin path): node:22-alpine sets no WORKDIR, so the container cwd is /; mds-core defaults a string compile's base directory to the cwd and NativeFs::canonicalizecheck_symlink_named calls Path::file_name() on /, which is None, and returns file-not-found before any I/O. A read-only cwd or any non-root directory works. Filed as #371 (napi, Python and CLI stdin string APIs; WASM unaffected). The gate now runs the container with -w /w (the read-only fixture directory — the shape every real non-root cwd has), the probe asserts process.cwd() === '/w' so a dropped flag fails loudly instead of resurfacing the error, and S21 pins -w /w as a needle. The compile() round-trip through the native binding is unchanged.

Why the readelf gate is insufficient

The existing gate reads the dynamic section and rejects glibc sonames (PF-038 shape). It proves ELF metadata, not that the addon dlopens: a NEEDED entry that node:22-alpine does not ship (for example libunwind.so.1) passes readelf and fails at require(). Only a real load on the target image catches that. This lands before the musl-linker change (#339) on purpose: that PR is proven by the readelf gate AND these load tests.

Why the fixture proves isMusl()

The loader (crates/mds-napi/index.js) tries, in order: mds-napi.node beside index.js, mds-napi.<key>.node beside index.js, then require('@mdscript/mds-napi-<key>'). The fixture is index.js + probe.cjs + ONLY the musl platform package under node_modules/@mdscript/, with the run block asserting: the binary exists, no .node beside index.js (candidate 2 would short-circuit), the gnu package is absent, exactly one scope and one package. A pass is therefore only possible through candidate 3 on the musl key, which is only computed when isMusl() returned true. Two traps avoided: napi artifacts --output-dir . writes every .node into the crate root (and napi-staged carries them), so the fixture is never built from crates/mds-napi/; and @mdscript/mds has a WASM fallback that would make any load test vacuous, so the probe never touches it.

The control fixture (same tree minus node_modules) runs FIRST and must fail; its log must contain the loader's throw prefix Failed to load mds-napi native binding for, mds-napi.<platform>.node and @mdscript/mds-napi-<platform>, and must not mention linux-<arch>-gnu. That failure is also the isMusl() proof (PF-013).

Why native arm64

The issue assumed aarch64 needs QEMU. It does not: public repos get free native ubuntu-24.04-arm runners (there is no ubuntu-latest-arm label). QEMU is disqualified on reliability (napi-rs's own CI notes Node segfaults under arm64 emulation; actions/runner-images#11471, tonistiigi/binfmt#215). Job-level container: is rejected by the runner on arm64 ("JavaScript Actions in Alpine containers are only supported on x64 Linux runners"), so both architectures use docker run from an ordinary host job.

Why Docker Hub by tag

GitHub-hosted runners are exempt from Docker Hub's anonymous pull limit for public images (docs.github.com/en/actions/reference/limits), so the mirror / non-blocking hedge in the issue is unnecessary (public.ecr.aws is a tighter tier; a non-blocking gate is decorative under ADR-013 / PF-017). The pull is bounded (3 attempts, timeout 300 each), the run uses --pull=never, --network none, a read-only mount, empty NODE_PATH/NODE_OPTIONS, and both docker run invocations are bounded at 600 s. The pulled digests are printed.

The not-cancelled wiring (PF-047)

publish-crates has an if: that starts with the not-cancelled status function, which removes the implicit success-of-dependencies gate: its needs: list is ordering-only and every dependency's result == 'success' must be an explicit conjunct. load-test-musl-arm64 is therefore added to BOTH the needs: list and the if:; a needs: entry without its conjunct would let a failed load test publish to crates.io (irreversible, PF-023). S21 pins both halves and a real-file mutation dropping only the conjunct turns it red.

ADR-013 accounting

  • The arm64 job is unguarded by design so it reaches success on every PR/dispatch run; it is NOT in TIER_B_EXPECTED_SKIPPED (M10c stays at 5 guarded jobs).
  • It is a Tier-B-binding check-run on release-surface PRs, deliberately NOT added to RELEASE_SURFACE_CONTEXTS (the 2026-09 verifier fixtures CF-1/CF-3/CF-5 predate it); S21 pins it instead. It is not a branch-protection context (release.yml jobs only run on release-surface PRs).
  • The x64 step sits after the staged upload so an x64 failure never suppresses the artifact; the arm64 job is then skipped and both re-run together after the fix.

Deviations from the issue's acceptance criteria (recorded explicitly)

  • arm64 is tested natively, not under QEMU.
  • The gate is blocking, not non-blocking, with the Docker Hub exemption documented and a bounded retry.
  • The x64 test runs after the staged upload rather than immediately after the readelf gate.

Verification

Local (all on the head): npm run test:gates 210 → 211 pass / 0 fail; node scripts/verify-no-control-bytes.mjs and node scripts/verify-versions.mjs pass; js-yaml parse: 11 jobs, runs-on: ubuntu-24.04-arm, publish-crates.needs = [version-gate, stage-and-verify-napi, load-test-musl-arm64, build-python, rehearse-publish-python], both load-test run blocks byte-equal; no ${{ in any comment, pypa/gh-action-pypi-publish count 2, no container:/setup-qemu; probe exits 2 without argv. PF-013 real-file controls, each turning S21 red: job id rename, runs-on: ubuntu-latest, dropping the publish-crates conjunct while keeping needs:, a one-character change in the arm64 run block, deleting one positive control echo, moving the x64 step above the upload, renaming the upload step (also with the old name left in a comment), deleting or commenting out if-no-files-found: error, removing the 600 s bound, flipping the arm64 PLATFORM, and removing the probe path from on.pull_request.paths (S10); the -w /w needle (removing it from all four docker-run lines turns S21 red). Local darwin-arm64 fixture: candidate order confirmed; from cwd / the same compile() call throws the #371 error locally, from any non-root cwd it returns "Hello alpine!\n"; the loader's throw carries all three needles with no gnu key.

CI (filled in before merge):

  • First PR release run 34274102158: load path green on x64 up to compile(), which threw the compile()/check()/lint() on a string fail when the base directory is a filesystem root ("cannot resolve path /: file not found: /") #371 error; the arm64 job was skipped downstream; no dispatch was made from that head.
  • Second PR release run (head 2e337ae) run 34276841793, success (~4 min 47 s); Stage + verify platform packages and Alpine load test (linux-arm64-musl) both success; exactly the five guarded jobs skipped. x64 evidence: pulled digests: ["node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32"], positive control OK: load failed without the musl package, on the musl key, musl detected via /usr/bin/ldd (52 bytes), platform=linux arch=x64, resolved @mdscript/mds-napi-linux-x64-musl -> /w/node_modules/@mdscript/mds-napi-linux-x64-musl/mds-napi.linux-x64-musl.node, load ok: compile(...) -> "Hello alpine!\n". arm64 evidence: positive control OK: the x86_64 sibling is not AArch64, musl detected via /usr/bin/ldd (53 bytes), platform=linux arch=arm64, resolved @mdscript/mds-napi-linux-arm64-musl -> /w/node_modules/@mdscript/mds-napi-linux-arm64-musl/mds-napi.linux-arm64-musl.node, load ok: compile(...) -> "Hello alpine!\n", docker server 28.0.4; the only ##[error] in either log is the control fixture's intentional loader failure. arm64 queue-to-start: 5 s (labels ubuntu-24.04-arm).
  • ci.yml on the head run 34276841792, success (all 16 jobs).
  • Branch dispatch run 34277584773 (https://github.com/dean0x/mdscript/actions/runs/34277584773), success (~3 min 19 s); CI history OK for 2e337aef30c2e6f38ffa45aeec187e48178c109e; identical evidence lines on both arches; five guarded jobs skipped; arm64 queue 5 s. One dispatch only: this PR changes no Rust, no build flag, no rust-cache input, and no cache key:, so there is no cache-key change to warm-prove.
  • node scripts/verify-pr-checks.mjs <pr> PASS: two release.yml suites (92858488304 workflow_dispatch, 92856374127 pull_request), five allowed-skipped lines each, release surface touched (2 file(s): .github/workflows/release.yml, scripts/musl-load-probe.cjs), all 15 required contexts completed+success, verified SHA 2e337ae.

Snyk: the local Snyk MCP server failed to connect (ENOENT on its macOS binary), so no local scan ran; the security/snyk (dean0x) PR check is the scan of record for scripts/musl-load-probe.cjs.

Related Issues

Closes #340
Refs #339 (lands after this; proven by the readelf gate and these load tests)
#351 unchanged (upstream maturin-action defect)
Refs #371 (product bug surfaced by this gate; cwd-root base directory)

- scripts/musl-load-probe.cjs: new CJS probe that runs inside node:22-alpine,
  validates isMusl() via /usr/bin/ldd, loads /w/index.js (the real loader),
  checks require.resolve path, verifies the 7 exports, and calls compile()
  with exact output assertion. Exit 2 on bad argv, exit 1 on step failures.

- scripts/__test__/release-auth-probe.spec.mjs:
  - Harden extractNeeds: call stripCommentLines before matching inline
    needs: [...] so comment lines like `# needs: [bogus]` are ignored.
  - Add B3a helpers: runsOnOf, loadTestRunBlock, stepIndexOf.
  - Append describe('B3a: Alpine musl load tests') with spec S21 (RED):
    8 planted positive controls (PF-013) all pass; first real-file assertion
    (load-test-musl-arm64 job exists) fails with the expected message.

Baseline: 210 pass / 0 fail. After: 210 pass / 1 fail (S21 RED).
…#340)

- RELEASING.md: document both musl load test steps (x64 in stage-and-verify-napi,
  arm64 in load-test-musl-arm64), new item 5 in What happens after tagging with
  renumbering, publish-crates now blocks on load-test-musl-arm64, six release-surface
  paths (add scripts/musl-load-probe.cjs), arm64 Tier-B-binding unguarded check note
- CHANGELOG.md: add [Unreleased] Internal bullet for #340 Alpine load tests
- .devflow/features/release-pipeline/KNOWLEDGE.md: Eleven Jobs (was Ten), updated
  DAG and job table, RELEASE_SURFACE six paths, D-PR7 note on load-test-musl-arm64
  Tier-B status, new Anti-Patterns (container: on arm64, missing result conjunct,
  positive-control counting without stripCommentLines, fixture-from-crate-dir,
  testing through @mdscript/mds), new Gotchas (napi-staged root .node files,
  extractNeeds comment-stripped, ldd musl script, Docker Hub exemption), updated
  Key Files list; test count 210 -> 211 (S21)
- scripts/musl-load-probe.cjs: wrap step 3 require('/w/index.js') in try/catch so
  a load failure prints ::error:: with the full loader message (per-candidate details)
  and exits 1, giving the workflow needles their signal (A4d)
…#340)

S21's byte-equality assertion compared run blocks that could absorb blank lines
from OUTSIDE either block: loadTestRunBlock scans to the end of the job section
when the load-test step is the last step, and stripCommentLines removes the next
job's banner but not the blank separator before it. Measured: inserting one blank
line before the load-test-musl-arm64 banner flipped byte-equality to false — S21
would have failed with a true verdict for a false reason on a purely cosmetic
edit to an unrelated job. Trailing blank lines are now dropped before joining;
blank lines are not shell code. Control PC-I pins both halves (trailing blanks
ignored, a real extra trailing command still detected) so the trim cannot swallow
a divergent script (PF-013).

RELEASING.md step 2 still said the excluded paths 'are not in the five paths
above' after the list above it grew to six with scripts/musl-load-probe.cjs —
the stale-count drift signal ADR-013's amendment calls out.

KNOWLEDGE.md named 'napi build --output-dir .' as the command that writes every
.node into the crate root; release.yml runs 'napi artifacts --output-dir .'
(line 441). The claim is load-bearing — it is why the fixture must be built from
the staged npm/ tree rather than the crate root.

Gates: npm run test:gates 211/0, verify-no-control-bytes clean, js-yaml 11 jobs.
…un bound and per-arch env; correct verdict wording (#340)

- release.yml: fix header comment 'five paths' -> 'six paths' (six on.pull_request.paths now listed)
- release.yml: restore backslash-newline continuation in both CTRL_LOG docker-run lines (was collapsed to ~25 spaces in both x64 and arm64 blocks; two-line form now matches real-run style)
- spec S21: add loadTestEnvBlock() helper; add PC-J/PC-K/PC-L positive controls; add non-vacuity guard + exact-name check before step-ordering assertion (rename 'Upload staged napi tree (v2)' now fails); pin if-no-files-found: error (E1); add 'timeout 600 docker run' to needles; extend PC-G; pin per-arch PLATFORM/ARCHKEY/NPM_DIR env values for both load-test steps
- musl-load-probe.cjs: wrap b.compile() in try/catch (step 6) so a throwing compile prints ::error:: with full message and exits 1
- RELEASING.md/KNOWLEDGE.md: reword 'two independent verdicts' -> artifact uploaded before x64 test so x64 failure never suppresses it; arm64 job is skipped when x64 fails, both re-run together after fix
…text (#340)

- stageStripped = stripCommentLines(stageSection) introduced once; Pin-E1
  and exact-name S21 assertions use it so a commented-out line cannot
  satisfy them.
- PC-K extended: a step with `# if-no-files-found: error` (commented)
  includes the raw text but is rejected by stripCommentLines, proving the
  strip is load-bearing (PF-013).
- musl-load-probe.cjs: every catch block now uses
  `(e && e.message) || String(e)` so a non-Error throw prints something
  useful instead of `undefined`.
- RELEASING.md Notes bullet: appended the skip-and-rerun half — when the
  x64 load test fails the arm64 job is skipped (its if: requires
  stage-and-verify-napi to succeed) and both re-run together.
…s cwd (#340, refs #371)

node:22-alpine sets no WORKDIR so 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 CI run on PR #370).

- release.yml: add -w /w after --pull=never on all four docker run invocations (both
  Alpine load-test blocks), with a 3-line explanatory comment above each
  "Positive control FIRST" comment; both run blocks remain byte-identical (S21)
- musl-load-probe.cjs: assert process.cwd() === '/w' immediately after argv guard
  (new Step 2); renumber former Steps 2-6 to 3-7; update header comment
- release-auth-probe.spec.mjs (S21): add '-w /w' to the needle list; extend PC-G
  to assert the planted text demonstrably lacks -w /w
- RELEASING.md: note the -w /w requirement and its provenance in the Notes bullet
- KNOWLEDGE.md (release-pipeline): add gotcha bullet documenting the Alpine cwd issue
- CHANGELOG.md: extend the #340 Unreleased bullet with the first-run finding
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(release): load-test the linux-x64-musl addon on Alpine before upload

1 participant