ci(release): load-test both musl addons on node:22-alpine before publish (#340) - #370
Merged
Conversation
- 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
This was referenced Sep 8, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Both musl napi addons (
linux-x64-musl,linux-arm64-musl) are now loaded for real onnode:22-alpinebefore anything publishes, andpublish-cratesblocks on both results.Alpine load test (linux-x64-musl)instage-and-verify-napi, placed after thenapi-stagedupload (which now hasif-no-files-found: error).load-test-musl-arm64(Alpine load test (linux-arm64-musl)) on a nativeubuntu-24.04-armrunner, restoring thenapi-stagedartifact, 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/lddcontainsmuslpredicate,require('/w/index.js')(the real loader),require.resolvemust land in/w/node_modules/…/mds-napi.<platform>.node, exactly the 7 exports, andcompile('Hello {{n}}!', { vars: { n: 'alpine' } })must returnkind: markdownwith output exactly"Hello alpine!\n"(dlopen binds lazily, so a call is required, not a typeof).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, thepublish-crateswiring, the step order, per-archenv:, the upload'sif-no-files-found: error, the docker bounds, and byte-equality of the two run blocks.extractNeedsis now comment-stripped.scripts/musl-load-probe.cjsis a sixth release-surface path in bothon.pull_request.pathsandRELEASE_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 thencompile()threwcannot resolve path /: file not found: /. Diagnosis (reproduced locally on macOS with the darwin addon and with the CLI's stdin path):node:22-alpinesets noWORKDIR, so the container cwd is/; mds-core defaults a string compile's base directory to the cwd andNativeFs::canonicalize→check_symlink_namedcallsPath::file_name()on/, which isNone, 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 assertsprocess.cwd() === '/w'so a dropped flag fails loudly instead of resurfacing the error, and S21 pins-w /was a needle. Thecompile()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
NEEDEDentry thatnode:22-alpinedoes not ship (for examplelibunwind.so.1) passes readelf and fails atrequire(). 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.nodebesideindex.js,mds-napi.<key>.nodebesideindex.js, thenrequire('@mdscript/mds-napi-<key>'). The fixture isindex.js+probe.cjs+ ONLY the musl platform package undernode_modules/@mdscript/, with the run block asserting: the binary exists, no.nodebesideindex.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 whenisMusl()returned true. Two traps avoided:napi artifacts --output-dir .writes every.nodeinto the crate root (andnapi-stagedcarries them), so the fixture is never built fromcrates/mds-napi/; and@mdscript/mdshas 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 prefixFailed to load mds-napi native binding for,mds-napi.<platform>.nodeand@mdscript/mds-napi-<platform>, and must not mentionlinux-<arch>-gnu. That failure is also theisMusl()proof (PF-013).Why native arm64
The issue assumed aarch64 needs QEMU. It does not: public repos get free native
ubuntu-24.04-armrunners (there is noubuntu-latest-armlabel). QEMU is disqualified on reliability (napi-rs's own CI notes Node segfaults under arm64 emulation; actions/runner-images#11471, tonistiigi/binfmt#215). Job-levelcontainer:is rejected by the runner on arm64 ("JavaScript Actions in Alpine containers are only supported on x64 Linux runners"), so both architectures usedocker runfrom 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.awsis a tighter tier; a non-blocking gate is decorative under ADR-013 / PF-017). The pull is bounded (3 attempts,timeout 300each), the run uses--pull=never,--network none, a read-only mount, emptyNODE_PATH/NODE_OPTIONS, and bothdocker runinvocations are bounded at 600 s. The pulled digests are printed.The not-cancelled wiring (PF-047)
publish-crateshas anif:that starts with the not-cancelled status function, which removes the implicit success-of-dependencies gate: itsneeds:list is ordering-only and every dependency'sresult == 'success'must be an explicit conjunct.load-test-musl-arm64is therefore added to BOTH theneeds:list and theif:; aneeds: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
successon every PR/dispatch run; it is NOT inTIER_B_EXPECTED_SKIPPED(M10c stays at 5 guarded jobs).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).Deviations from the issue's acceptance criteria (recorded explicitly)
Verification
Local (all on the head):
npm run test:gates210 → 211 pass / 0 fail;node scripts/verify-no-control-bytes.mjsandnode scripts/verify-versions.mjspass; 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-publishcount 2, nocontainer:/setup-qemu; probe exits 2 without argv. PF-013 real-file controls, each turning S21 red: job id rename,runs-on: ubuntu-latest, dropping thepublish-cratesconjunct while keepingneeds:, a one-character change in the arm64 run block, deleting onepositive controlecho, moving the x64 step above the upload, renaming the upload step (also with the old name left in a comment), deleting or commenting outif-no-files-found: error, removing the 600 s bound, flipping the arm64PLATFORM, and removing the probe path fromon.pull_request.paths(S10); the-w /wneedle (removing it from all four docker-run lines turns S21 red). Local darwin-arm64 fixture: candidate order confirmed; from cwd/the samecompile()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):
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.Stage + verify platform packagesandAlpine 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 (labelsubuntu-24.04-arm).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 cachekey:, 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 forscripts/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)