From aab25f74c204acb96cf1040e91a0b3f3fbf5151f Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 21:33:14 +0300 Subject: [PATCH 1/8] test(release): musl Alpine load probe + spec S21 (RED) for #340 - 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). --- scripts/__test__/release-auth-probe.spec.mjs | 394 ++++++++++++++++++- scripts/musl-load-probe.cjs | 114 ++++++ 2 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 scripts/musl-load-probe.cjs diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index f0c67541..914e6eca 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,395 @@ 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. + */ +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); + } + 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; +} + +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. + 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)', + ); + + // 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)', + ); + + // ----------------------------------------------------------------------- + // 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'); + + // 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', + 'timeout 300', + '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'); + + // 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 ('); + 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..bd28b7ca --- /dev/null +++ b/scripts/musl-load-probe.cjs @@ -0,0 +1,114 @@ +// 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 -v :/w:ro \ +// node /w/probe.cjs +// +// 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: 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.message + '\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 3: Load the real loader — never require the .node directly and never +// @mdscript/mds (its WASM fallback would make the test vacuous). +const b = require('/w/index.js'); + +// Step 4: 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.message + '\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 5: 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 6: 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', ... } +const r = b.compile('Hello {{n}}!', { vars: { n: 'alpine' } }); +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'); From eb00ae23bd1eb49ccb721f38b3de058832bca03e Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 21:46:05 +0300 Subject: [PATCH 2/8] ci(release): Alpine node:22-alpine load tests for both musl addons before publish (#340) --- .github/workflows/release.yml | 169 +++++++++++++++++++++++++++++++++- scripts/verify-pr-checks.mjs | 1 + 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2282b87e..5066bffc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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,163 @@ 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}}' + # 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=$(docker run --rm --network none --pull=never -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" + docker run --rm --network none --pull=never -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}}' + # 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=$(docker run --rm --network none --pull=never -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" + docker run --rm --network none --pull=never -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 +1149,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/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', ]; /** From 29e79852e4c742167b8a7d804d305abdc94f07c8 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 22:04:26 +0300 Subject: [PATCH 3/8] docs(release): document the Alpine musl load tests and their controls (#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) --- .../features/release-pipeline/KNOWLEDGE.md | 70 ++++++++++++++++--- CHANGELOG.md | 1 + RELEASING.md | 54 +++++++++----- scripts/musl-load-probe.cjs | 11 ++- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index a1bd5b9f..27d64620 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,21 @@ 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 (two +independent verdicts; x64 failure never suppresses the artifact the arm64 job needs). + 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 +93,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 +101,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 +162,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 +187,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 +294,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 build + --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 +351,37 @@ 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. ## 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 -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/CHANGELOG.md b/CHANGELOG.md index 9f5f6176..ea894476 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). ## [0.4.2] — 2026-09-03 diff --git a/RELEASING.md b/RELEASING.md index fd31c77a..6768642c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -202,9 +202,9 @@ 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 @@ -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,30 @@ 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 never suppressed by + an x64 failure (two independent verdicts: x64 and arm64). + 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. Provides an independent verdict on the arm64 musl addon + before any registry write (PF-038, #340). `publish-crates` needs this + job and requires `needs.load-test-musl-arm64.result == 'success'` in its + `if:` (PF-047). + 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 +402,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). - 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/musl-load-probe.cjs b/scripts/musl-load-probe.cjs index bd28b7ca..10d2516c 100644 --- a/scripts/musl-load-probe.cjs +++ b/scripts/musl-load-probe.cjs @@ -50,7 +50,16 @@ process.stdout.write( // Step 3: Load the real loader — never require the .node directly and never // @mdscript/mds (its WASM fallback would make the test vacuous). -const b = require('/w/index.js'); +// 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.message + '\n'); + process.exit(1); +} // Step 4: Verify require.resolve path for the musl platform package. // Must start with /w/node_modules/ and end with mds-napi..node, From ed51273186bc7513735d3b07e502b24d0e589795 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 22:23:34 +0300 Subject: [PATCH 4/8] fix(release): de-flap S21 run-block equality; correct stale doc facts (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/release-pipeline/KNOWLEDGE.md | 4 +- RELEASING.md | 2 +- scripts/__test__/release-auth-probe.spec.mjs | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index 27d64620..942e859b 100644 --- a/.devflow/features/release-pipeline/KNOWLEDGE.md +++ b/.devflow/features/release-pipeline/KNOWLEDGE.md @@ -305,8 +305,8 @@ such a file. Never write `${{` in comments; describe it in words. - **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 build - --output-dir .` writes every `.node` into the crate root; the artifact also carries +- **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 diff --git a/RELEASING.md b/RELEASING.md index 6768642c..6cdf3bb4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -210,7 +210,7 @@ cases (the six release-surface paths are `.github/workflows/release.yml`, 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. diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index 914e6eca..41c1338a 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -1346,6 +1346,17 @@ function runsOnOf(section) { * (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'); @@ -1367,6 +1378,7 @@ function loadTestRunBlock(section) { if (lineIndent <= runLineIndent) break; runLines.push(line); } + while (runLines.length > 0 && runLines[runLines.length - 1].trim() === '') runLines.pop(); return runLines.join('\n'); } return null; @@ -1528,6 +1540,34 @@ describe('B3a: Alpine musl load tests (#340)', () => { '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)', + ); + // ----------------------------------------------------------------------- // 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). From c662f0f64a9425c27953d5acc8a4f804bc4406c9 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 22:28:59 +0300 Subject: [PATCH 5/8] ci(release): bound both Alpine docker run invocations at 600 s (#340) --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5066bffc..45ba525c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -496,7 +496,7 @@ jobs: docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' # 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=$(docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -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 @@ -508,7 +508,7 @@ jobs: 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" - docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= \ + timeout 600 docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= \ -v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" # --------------------------------------------------------------------------- @@ -593,7 +593,7 @@ jobs: docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' # 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=$(docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -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 @@ -605,7 +605,7 @@ jobs: 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" - docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= \ + timeout 600 docker run --rm --network none --pull=never -e NODE_PATH= -e NODE_OPTIONS= \ -v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" # --------------------------------------------------------------------------- From f704d1f5de9084cc642f10bfc5eb54d150d2c206 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 22:57:13 +0300 Subject: [PATCH 6/8] fix(release): pin S21 non-vacuity, upload if-no-files-found, docker-run 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 --- .../features/release-pipeline/KNOWLEDGE.md | 5 +- .github/workflows/release.yml | 8 +- RELEASING.md | 14 +- scripts/__test__/release-auth-probe.spec.mjs | 136 +++++++++++++++++- scripts/musl-load-probe.cjs | 8 +- 5 files changed, 158 insertions(+), 13 deletions(-) diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index 942e859b..50aec439 100644 --- a/.devflow/features/release-pipeline/KNOWLEDGE.md +++ b/.devflow/features/release-pipeline/KNOWLEDGE.md @@ -82,8 +82,9 @@ Job details: `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 (two -independent verdicts; x64 failure never suppresses the artifact the arm64 job needs). +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). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45ba525c..6ac5db72 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: @@ -496,7 +496,8 @@ jobs: docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' # 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 -e NODE_PATH= -e NODE_OPTIONS= -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -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 @@ -593,7 +594,8 @@ jobs: docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}' # 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 -e NODE_PATH= -e NODE_OPTIONS= -v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then + if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -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 diff --git a/RELEASING.md b/RELEASING.md index 6cdf3bb4..0e10bb81 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -362,16 +362,18 @@ The `release.yml` workflow runs, in order: 4. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies 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 never suppressed by - an x64 failure (two independent verdicts: x64 and arm64). + 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. Provides an independent verdict on the arm64 musl addon - before any registry write (PF-038, #340). `publish-crates` needs this - job and requires `needs.load-test-musl-arm64.result == 'success'` in its - `if:` (PF-047). + 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 diff --git a/scripts/__test__/release-auth-probe.spec.mjs b/scripts/__test__/release-auth-probe.spec.mjs index 41c1338a..24314c5c 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -1402,6 +1402,36 @@ function stepIndexOf(section, needle) { 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)', () => { // ------------------------------------------------------------------------- @@ -1518,12 +1548,17 @@ describe('B3a: Alpine musl load tests (#340)', () => { 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. + // S21/PC-G: a step text missing --network none is detectable; the same + // planted text also lacks timeout 600 docker run (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)', + ); // S21/PC-H: extractNeeds strips comment lines before matching (hardening). // A `# needs: [bogus]` comment above ` needs: [real-dep]` must yield @@ -1568,6 +1603,58 @@ describe('B3a: Alpine musl load tests (#340)', () => { '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-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). @@ -1694,6 +1781,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { ':/w:ro', '--pull=never', 'timeout 300', + 'timeout 600 docker run', 'probe.cjs', 'NODE_PATH=', ]; @@ -1725,9 +1813,55 @@ describe('B3a: Alpine musl load tests (#340)', () => { 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( + stageSection.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(stageSection), + '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}) ` + diff --git a/scripts/musl-load-probe.cjs b/scripts/musl-load-probe.cjs index 10d2516c..71696a66 100644 --- a/scripts/musl-load-probe.cjs +++ b/scripts/musl-load-probe.cjs @@ -104,7 +104,13 @@ if (actualExports !== EXPECTED_EXPORTS) { // Step 6: 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', ... } -const r = b.compile('Hello {{n}}!', { vars: { n: 'alpine' } }); +let r; +try { + r = b.compile('Hello {{n}}!', { vars: { n: 'alpine' } }); +} catch (e) { + process.stderr.write('::error::compile() threw: ' + e.message + '\n'); + process.exit(1); +} if (r.kind !== 'markdown') { process.stderr.write( '::error::compile() returned kind=' + JSON.stringify(r.kind) + ', expected "markdown"\n', From ebeb7c2878d151fbad717408fbe44bc140ee0d7b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 23:16:04 +0300 Subject: [PATCH 7/8] test(release): comment-strip the S21 upload pins; harden probe error text (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- RELEASING.md | 2 +- scripts/__test__/release-auth-probe.spec.mjs | 27 ++++++++++++++++++-- scripts/musl-load-probe.cjs | 8 +++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 0e10bb81..43ee45a1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -404,7 +404,7 @@ The `release.yml` workflow runs, in order: ## Notes -- The 7 native napi targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc. x86_64-gnu passes napi's --use-napi-cross; aarch64-gnu links with the apt cross gcc; both musl legs link with zig cc wrappers, and a release gate asserts each musl artifact links musl rather than glibc (see the build-napi matrix in release.yml). zig is pinned to 0.16.0 in release.yml's Install zig step; bump it deliberately, since zig cc's linker-arg allowlist changes between releases. Both musl addons are load-tested on `node:22-alpine` before anything publishes: the x64 load test is the last step of `stage-and-verify-napi` (placed after the staged artifact upload so the artifact is preserved even when the x64 test fails), and the arm64 load test runs in the separate unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact. The fixture is `index.js` + `scripts/musl-load-probe.cjs` + only the musl platform package under `node_modules/@mdscript/`, so a pass is proof the loader's `isMusl()` returned true; a control fixture without the package must fail first (PF-013). `publish-crates` blocks on both via its `needs:` list AND its `if:` conjunct (PF-047). The readelf gate proves ELF metadata (no glibc soname) but not that the addon dlopens on Alpine — a NEEDED entry that Alpine does not ship (e.g. `libunwind.so.1`) is invisible to it; only a real load on `node:22-alpine` catches that (PF-038 shape). +- 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 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 24314c5c..2b438871 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -1634,6 +1634,28 @@ describe('B3a: Alpine musl load tests (#340)', () => { '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 = [ @@ -1757,6 +1779,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { 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, @@ -1815,7 +1838,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { // Pin E1: the staged upload must fail loudly on an empty tree (#340). assert.ok( - stageSection.includes('if-no-files-found: error'), + 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)', ); @@ -1857,7 +1880,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { // (#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(stageSection), + /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)', diff --git a/scripts/musl-load-probe.cjs b/scripts/musl-load-probe.cjs index 71696a66..427cb96b 100644 --- a/scripts/musl-load-probe.cjs +++ b/scripts/musl-load-probe.cjs @@ -31,7 +31,7 @@ let lddContent; try { lddContent = readFileSync('/usr/bin/ldd', 'utf-8'); } catch (e) { - process.stderr.write('::error::Cannot read /usr/bin/ldd: ' + e.message + '\n'); + process.stderr.write('::error::Cannot read /usr/bin/ldd: ' + ((e && e.message) || String(e)) + '\n'); process.exit(1); } if (!lddContent.includes('musl')) { @@ -57,7 +57,7 @@ let b; try { b = require('/w/index.js'); } catch (e) { - process.stderr.write('::error::require(\'/w/index.js\') failed: ' + e.message + '\n'); + process.stderr.write('::error::require(\'/w/index.js\') failed: ' + ((e && e.message) || String(e)) + '\n'); process.exit(1); } @@ -69,7 +69,7 @@ let resolved; try { resolved = require.resolve(pkg); } catch (e) { - process.stderr.write('::error::require.resolve(\'' + pkg + '\') failed: ' + e.message + '\n'); + process.stderr.write('::error::require.resolve(\'' + pkg + '\') failed: ' + ((e && e.message) || String(e)) + '\n'); process.exit(1); } if (!resolved.startsWith('/w/node_modules/')) { @@ -108,7 +108,7 @@ let r; try { r = b.compile('Hello {{n}}!', { vars: { n: 'alpine' } }); } catch (e) { - process.stderr.write('::error::compile() threw: ' + e.message + '\n'); + process.stderr.write('::error::compile() threw: ' + ((e && e.message) || String(e)) + '\n'); process.exit(1); } if (r.kind !== 'markdown') { From 2e337aef30c2e6f38ffa45aeec187e48178c109e Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 8 Sep 2026 23:47:08 +0300 Subject: [PATCH 8/8] ci(release): run the Alpine load-test container from /w; probe asserts 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 --- .../features/release-pipeline/KNOWLEDGE.md | 9 ++++++- .github/workflows/release.yml | 14 +++++++--- CHANGELOG.md | 2 +- RELEASING.md | 2 +- scripts/__test__/release-auth-probe.spec.mjs | 8 +++++- scripts/musl-load-probe.cjs | 26 ++++++++++++++----- 6 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.devflow/features/release-pipeline/KNOWLEDGE.md b/.devflow/features/release-pipeline/KNOWLEDGE.md index 50aec439..dbe4c9ef 100644 --- a/.devflow/features/release-pipeline/KNOWLEDGE.md +++ b/.devflow/features/release-pipeline/KNOWLEDGE.md @@ -367,6 +367,13 @@ such a file. Never write `${{` in comments; describe it in words. 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 @@ -376,7 +383,7 @@ such a file. Never write `${{` in comments; describe it in words. `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 -v :/w:ro node /w/probe.cjs `. + `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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ac5db72..a536590e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -494,9 +494,12 @@ jobs: # 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 -e NODE_PATH= -e NODE_OPTIONS= \ + 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 @@ -509,7 +512,7 @@ jobs: 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 -e NODE_PATH= -e NODE_OPTIONS= \ + 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}" # --------------------------------------------------------------------------- @@ -592,9 +595,12 @@ jobs: # 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 -e NODE_PATH= -e NODE_OPTIONS= \ + 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 @@ -607,7 +613,7 @@ jobs: 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 -e NODE_PATH= -e NODE_OPTIONS= \ + 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}" # --------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index ea894476..b854d0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +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). +- 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 43ee45a1..1a2197d1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -404,7 +404,7 @@ The `release.yml` workflow runs, in order: ## Notes -- The 7 native napi targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc. x86_64-gnu passes napi's --use-napi-cross; aarch64-gnu links with the apt cross gcc; both musl legs link with zig cc wrappers, and a release gate asserts each musl artifact links musl rather than glibc (see the build-napi matrix in release.yml). zig is pinned to 0.16.0 in release.yml's Install zig step; bump it deliberately, since zig cc's linker-arg allowlist changes between releases. Both musl addons are load-tested on `node:22-alpine` before anything publishes: the x64 load test is the last step of `stage-and-verify-napi` (placed after the staged artifact upload so the artifact is preserved even when the x64 test fails), and the arm64 load test runs in the separate unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact. The fixture is `index.js` + `scripts/musl-load-probe.cjs` + only the musl platform package under `node_modules/@mdscript/`, so a pass is proof the loader's `isMusl()` returned true; a control fixture without the package must fail first (PF-013). `publish-crates` blocks on both via its `needs:` list AND its `if:` conjunct (PF-047). The readelf gate proves ELF metadata (no glibc soname) but not that the addon dlopens on Alpine — a NEEDED entry that Alpine does not ship (e.g. `libunwind.so.1`) is invisible to it; only a real load on `node:22-alpine` catches that (PF-038 shape). When the x64 load test fails, the arm64 job is skipped (its `if:` requires `stage-and-verify-napi` to succeed) and both tests are re-run together after the fix. +- The 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 2b438871..ea16e98d 100644 --- a/scripts/__test__/release-auth-probe.spec.mjs +++ b/scripts/__test__/release-auth-probe.spec.mjs @@ -1549,7 +1549,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { '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 (pin E2, #340, PF-013). + // 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'), @@ -1559,6 +1559,11 @@ describe('B3a: Alpine musl load tests (#340)', () => { !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 @@ -1803,6 +1808,7 @@ describe('B3a: Alpine musl load tests (#340)', () => { '--network none', ':/w:ro', '--pull=never', + '-w /w', 'timeout 300', 'timeout 600 docker run', 'probe.cjs', diff --git a/scripts/musl-load-probe.cjs b/scripts/musl-load-probe.cjs index 427cb96b..50e41d8e 100644 --- a/scripts/musl-load-probe.cjs +++ b/scripts/musl-load-probe.cjs @@ -2,9 +2,12 @@ // #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 -v :/w:ro \ +// 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'; @@ -22,7 +25,18 @@ if (process.argv.length !== 3 || !VALID_PLATFORMS.includes(platform)) { process.exit(2); } -// Step 2: Verify musl via /usr/bin/ldd — re-implements isMusl() from index.js +// 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). @@ -48,7 +62,7 @@ process.stdout.write( 'platform=' + process.platform + ' arch=' + process.arch + '\n', ); -// Step 3: Load the real loader — never require the .node directly and never +// 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 @@ -61,7 +75,7 @@ try { process.exit(1); } -// Step 4: Verify require.resolve path for the musl platform package. +// 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; @@ -89,7 +103,7 @@ if (!resolved.endsWith(expectedSuffix)) { } process.stdout.write('resolved ' + pkg + ' -> ' + resolved + '\n'); -// Step 5: Verify exports are exactly the 7 required keys. +// 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) { @@ -101,7 +115,7 @@ if (actualExports !== EXPECTED_EXPORTS) { process.exit(1); } -// Step 6: Compile smoke test. dlopen binds lazily so symbols resolve at CALL +// 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;