MDS ships as a single coordinated release: both crates and all npm packages go out together at the same version. This document is the ordered runbook.
The release is deliberately a manual, triggered step. Pushing a
v*tag is what starts it. Until then, nothing publishes.
The version-consistency gate (run in CI and locally) asserts these are all equal before anything publishes:
- Workspace crate version —
Cargo.toml[workspace.package] version(coversmds-core,mds-cli,mds-wasm,mds-napi) - Every publishable
package.json:@mdscript/mds-napi,@mdscript/mds,@mdscript/mds-wasm,@mdscript/bundler-utils,@mdscript/vite-plugin,@mdscript/rollup-plugin,@mdscript/webpack-loader,@mdscript/rspack-loader - All internal
@mdscript/*dependency ranges are^<version>(nofile:) - The
markdown-scriptPython wheel version — maturin stamps it dynamically from the Cargo workspace at build time. The gate assertspyproject.tomlnames the packagemarkdown-script(ADR-012) and keeps"version"indynamic[].
These are not automated and must be done before the first release:
-
Register the
@mdscriptnpm organization (or scope) so the scoped packages can be published. -
Configure npm publish auth — either:
- npm trusted publisher / OIDC for this repo's
release.yml(preferred; no long-lived token), or - add an
NPM_TOKENrepo secret with publish rights to@mdscript/*. Provenance requires theid-token: writepermission (already set on the publish job) plus publishing from GitHub Actions.
- npm trusted publisher / OIDC for this repo's
-
Add the
CARGO_REGISTRY_TOKENrepo secret with publish rights tomds-coreandmds-clion crates.io. The workflow cannot probe this token (crates.io has no read-only endpoint that accepts a scoped token — see the credential probe under Pre-flight); verify it in the crates.io UI before tagging (PF-023). -
Enable GitHub private vulnerability reporting (Settings → Code security → Private vulnerability reporting) so the SECURITY.md flow works.
-
Configure PyPI trusted publisher for
markdown-scriptat pypi.org/manage/account/publishing:- Project name:
markdown-script - Owner / repository:
dean0x/mdscript - Workflow filename:
release.yml(must match exactly) - Environment name: leave blank — the
publish-pythonjob has noenvironment:field; a named environment would cause PyPI to reject the OIDC token because the claim would not match the filed record.
Note: A PyPI pending publisher is not a name reservation and blocks no one — it auto-expires ~30 days after creation unless a first upload actually lands (ADR-012 amendment). Only the first
pypa/gh-action-pypi-publishrun on a real tag push secures the name.PyPI OIDC probe: The
version-gatejob also probes the PyPI trusted publisher by performing the OIDC mint-token exchange (pypi.org/_/oidc/mint-token) before any irreversible publish. A missing, expired, or mismatched trusted-publisher record causes version-gate to fail, aborting the release before any crates.io or npm publish runs. The minted token expires unused — the probe is free and safe. This step is NOT tag-guarded so it runs in theworkflow_dispatchdry run too, exercising the PyPI trust chain before the real tag push (PF-039). It is run onpull_requestevents too — the version-gate step fails closed on fork and Dependabot PRs (they receive noid-token: writeand no repository secrets), and no PR run can reach a publish in any case. - Project name:
-
Configure TestPyPI trusted publisher (optional, needed for
testpypi: truedispatch runs) at test.pypi.org/manage/account/publishing:- Project name:
markdown-script - Owner / repository:
dean0x/mdscript - Workflow filename:
release.yml - Environment name: leave blank
The
publish-testpypijob is a dispatch-input-guarded opt-in leg (testpypi: trueonworkflow_dispatch). It is skipped on all PR and standard dispatch runs;TIER_B_EXPECTED_SKIPPEDlists its name so the pre-merge verifier tolerates the skipped conclusion. The trusted publisher for TestPyPI is independent of the PyPI one — both must be configured separately.To trigger the first upload and lock the TestPyPI name, run:
gh workflow run release.yml --ref <branch> -f testpypi=trueA boolean dispatch input cannot be set without-f; omitting it leavestestpypiat its default (false) andpublish-testpypiis skipped.Publisher expiry: a PyPI pending publisher auto-expires ~30 days after creation unless an upload lands. For TestPyPI, the first
workflow_dispatchrun withtestpypi: trueis the first upload that locks the name. If the pending publisher is missing or expired, thePublish to TestPyPI (rehearsal)job fails at the OIDC token exchange with aninvalid-publishermessage — re-file the pending publisher (environment name BLANK) and re-dispatch; no code change needed. - Project name:
Run the local dry-runs and gates:
# Rust
cargo test --workspace
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
# Rustdoc gate (mirrors the CI `rust` job). nextest, clippy, and `cargo test --doc`
# all miss broken private intra-doc links; only this command catches them.
RUSTDOCFLAGS="-D warnings" cargo doc -p mds-core --no-deps
cargo publish -p mds-core --dry-run
# NOTE: `cargo publish -p mds-cli --dry-run` fails locally with
# "no matching package named `mds-core` found" until mds-core is on crates.io —
# mds-cli has a path+version dep on it. This is expected; the release workflow
# publishes mds-core first (and waits for the index), then mds-cli.
# JS
npm ci
npm run build -w @mdscript/mds-wasm
npm run build --workspaces --if-present
npm test --workspaces --if-present
node scripts/verify-versions.mjs
# Verify #[deprecated(since = ...)] attributes match the release version.
# bump-version.mjs rewrites manifests and CHANGELOG only -- never .rs files.
# Every hit's version must be <= X.Y.Z. A deprecation introduced in THIS release
# must equal X.Y.Z; pre-existing ones keep their original version.
# PF-018: if the grep returns no hits, plant a temporary `since = "x.y.z"` in any
# .rs file, confirm the grep finds it, then remove it before proceeding.
grep -rn 'since = ' crates/ --include='*.rs'
# Source hygiene and pre-merge check gates
node scripts/verify-no-control-bytes.mjs
npm run test:gates # positive-control spec suite
# Before any --admin merge (PF-017 guard — cancelled runs read as green):
PR_NUMBER=NNN # replace NNN with the bump PR number
node scripts/verify-pr-checks.mjs "$PR_NUMBER"
# Note: a branch dry-run's skipped publish jobs are tolerated by the verifier.
# Packaging spot-check (inspect tarball contents)
npm pack -w @mdscript/mds --dry-run
npm pack -w @mdscript/mds-wasm --dry-run
npm pack -w @mdscript/mds-napi --dry-run
# Python — local wheel build and install smoke (mirrors ci.yml's python-wheel job)
# Note: cross-platform Python wheels can only be verified in CI (manylinux/musl
# Docker containers are not reproduced locally). Use the branch dry-run below
# instead of trying to replicate the musl readelf gate locally. PF-036.
python -m venv .venv && . .venv/bin/activate
pip install "maturin==1.13.3" pytest
maturin build -m crates/mds-python/Cargo.toml --out dist
ls dist/ | grep -q 'cp311-abi3' || (echo "expected a cp311-abi3 wheel" && exit 1)
pip install --find-links dist --no-index markdown-script
python -c "import markdown_script as m; r = m.compile('Hello {{n}}!', vars={'n': 'CI'}); print('smoke ok:', r.output)"Then validate the risky cross-compile + platform packaging without publishing, via the dry-run workflow:
gh workflow run release.yml # workflow_dispatch — builds the 7-target
# napi matrix AND the 7-target + sdist
# Python wheel matrix, stages packages,
# runs the A3 name<->loader gate and the
# Python readelf linkage gate, uploads
# artifacts. Rehearses the publish-python
# step. Publishes NOTHING.The dry-run workflow runs version-gate in full, which includes the
credential probe (security-08). What each registry check proves:
- npm (
npm whoami): proves the token is accepted by the npm registry (authentication). Does NOT prove publish rights to the@mdscriptscope — a read-only or wrongly-scoped token passes this check but fails at publish time. - PyPI: the OIDC mint-token exchange (
pypi.org/_/oidc/mint-token) proves the trusted-publisher record matches the workflow. The minted token expires unused — the probe is free and safe. - crates.io: non-empty guard only. This is the strongest check the crates.io
API allows for an API token:
GET /api/v1/meisAuthCheck::only_cookie()(src/controllers/user/me.rs:38-41) and returns HTTP 403 for any token, scoped or unscoped (src/auth.rs:136-144). The only token-accepting read endpoint,GET /api/v1/me/tokens/{id}(src/controllers/token.rs:269-282), accepts legacy unscoped tokens only — a scoped token (the least-privilege kind a publish secret should be) is rejected there with HTTP 403 "this token does not have the required permissions". A well-formed but revoked/deleted token gets HTTP 403 "authentication failed" (src/auth.rs:297-303); a malformed token gets HTTP 401 "The given API token does not match the format used by crates.io" (src/auth.rs:295,InsecurelyGeneratedTokenRevoked). Every crates.io API request must also carry aUser-Agentheader — without one therequire_user_agentmiddleware (src/middleware/require_user_agent.rs:35-47) returns HTTP 403 with a plaintext body before authentication runs, so a manual probe without that header fails for an unrelated reason. In practice this means a revoked crates.io token is first detected at the firstcargo publish(fail-before-write, after the build matrix has been paid for). The v0.4.0 release experienced exactly this (run 33569514359 attempt 1 failed atPublish mds-corewith HTTP 403, nothing published,gh run rerun --failedcompleted it; see PF-023). A durable fix — Trusted Publishing for crates.io — is tracked in #368; #345 is closed as won't-fix-as-filed with this finding.
All three checks run on every event, including pull_request. On fork and
Dependabot PRs — which receive no repository secrets and no id-token: write —
they fail closed with an actionable error: maintainers must supersede with a
first-party branch PR or dispatch gh workflow run release.yml --ref <branch>.
On release-surface PRs a fail-closed Version gate blocks the merge because
D-PR7 requires it (by design, not advisory) — supersede with a first-party branch
PR or dispatch by hand.
Even though release-surface PRs now trigger release.yml automatically, a
manual gh workflow run release.yml --ref <branch> is still required in four
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):
- CI-history gate (PF-017) — the gate is step-skipped on
pull_requestbecausegithub.shais the ephemeral merge commit, not the branch head; a::notice::makes the skip visible. It runs only on tag push and dispatch. - Changes outside the release surface — dependency sweeps,
crates/mds-core/**,Cargo.toml, andpackage.jsonare not in the six paths above and do not trigger apull_requestrun onrelease.yml. - Dependabot and fork PRs — no repository secrets and no
id-token: write;Version gatefails closed with the "No Actions secrets on this run" error. Do not merge on those checks; supersede with a maintainer-authored PR or dispatch by hand. - TestPyPI handshake —
gh workflow run release.yml --ref <branch> -f testpypi=true(once per version;skip-existing: truemakes repeats no-ops).
The dry run also exercises the CI-history gate (PF-017), asserting a
completed+success CI run for the dispatched ref's HEAD. Dispatch it only after
that ref's CI has finished, or the gate fails closed on a still-running run. The
gate is skipped on pull_request runs by a step-level guard (the sibling notice
step makes the skip visible) and is enforced unchanged on tag push and
workflow_dispatch.
The dry run also runs the rehearse-publish-python job (Rehearse PyPI publish (no upload)), which rehearses everything about publish-python except
the one irreversible act. It proves four properties, each with a positive
control (PF-013 — a gate never observed rejecting anything is not evidence):
- Pin shape — the
pypa/gh-action-pypi-publishpin must be avX.Y.Zrelease tag. Control: the image-backed commitdc37677b...and the annotated tag objecta892a5a6...are both rejected — policy, not existence. - GHCR manifest — GHCR must hold an image for that exact ref (HTTP 200).
Control: a ref that cannot exist must return 404 with
MANIFEST_UNKNOWN. docker pull— the image must actually pull. Control: a missing tag must fail; bounded to 3 attempts. ("The ref resolves in git" is necessary and never sufficient — PF-040.)twine check— the image's own twine 7.0.0 runs against all 8 distributions with--network noneand--entrypoint twine, so the step physically cannot reach pypi.org. Control: a deliberately corrupt wheel must be rejected.
The job must never
uses: pypa/gh-action-pypi-publish. The action has no dry-run/no-upload mode: an unrecogniseddry-run:input is warned about and ignored, and the action then uploads for real. Adry-run: truerehearsal shipped briefly and attempted a live pypi.org upload from a pull request (run 34060146952); it failed only because that version was already published. Spec S14 inscripts/__test__/release-auth-probe.spec.mjsnow pins the invocation topublish-pythonandpublish-testpypionly, and the rehearsal is deniedid-tokenso it holds no credential to upload with.
The rehearsal proves everything listed above; it cannot prove the upload handshake
and trusted-publisher exchange at publish time — the action has no dry-run mode.
The credential half is covered by version-gate's OIDC probe (which runs on
every event including PRs); the upload half is closed by the opt-in TestPyPI leg
(-f testpypi=true), which exercises the full exchange once per version.
publish-crates needs this job, so a broken pin aborts the release before the
irreversible crates.io write. It is intentionally unguarded, so it runs on
pull_request and workflow_dispatch, not just tag pushes (PF-039).
Five jobs are expected-skipped on a standard workflow_dispatch dry run and
are listed in TIER_B_EXPECTED_SKIPPED in scripts/verify-pr-checks.mjs:
Publish to crates.io, Publish to npm, Publish to PyPI, GitHub Release,
and Publish to TestPyPI (rehearsal). The same five are skipped on a
release-surface PR run. The skipped tolerance applies only to check-runs whose
check_suite.id maps (via GET /actions/runs?head_sha=) to a release.yml
check suite (any event); the three D-PR7 contexts (Version gate,
Stage + verify platform packages, Rehearse PyPI publish (no upload)) are
attributed by the same suite. The verifier exits 2 when it cannot enumerate the
head's workflow runs (D-PR8, #341).
Confirm the A3 name-gate step (scripts/verify-napi-names.mjs) passes in that
run. This is a hard checkpoint — if the generated platform package names or
their .node filenames drift from the hand-written crates/mds-napi/index.js
loader, the published universal package will fail to load the native binary at
runtime on the affected platform. Do not proceed past a failing gate.
Release-surface PRs — those touching .github/workflows/release.yml,
.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 (no upload). All other publish jobs are skipped, and their skipped conclusions
are tolerated by the verifier.
That path list lives in two places that must stay identical: the
on.pull_request.paths filter in release.yml and RELEASE_SURFACE in
scripts/verify-pr-checks.mjs. Spec S10 compares them as sets — a filter the
verifier does not know about would let a release-surface PR pass as a silent
no-run (ADR-013 amendment). The verifier also fails closed (exit 2) if it
cannot enumerate the PR's changed files at all. The skipped-publish allowance
and D-PR7 context attribution are both keyed on the check-run's release.yml
check suite (D-PR8, #341 — any release.yml event counts, including pull_request
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).
The release is driven by pushing a vX.Y.Z tag. This is how all versions have shipped.
- Bump versions:
node scripts/bump-version.mjs X.Y.Z(updates all manifests and stamps the CHANGELOG, opening a fresh[Unreleased]). - Land the bump on
main: open a PR (CI-gated). Once CI is green, run the pre-merge check verifier before merging — a cancelled run reads as green under--admin(PF-017):On exit 0 the script prints the exact merge command — copy and run it verbatim:node scripts/verify-pr-checks.mjs <pr-number>
(gh pr merge --squash --admin --match-head-commit <headSha>
--adminis required becausemainis protected and the sole code-owner cannot self-approve.--match-head-commitcloses the TOCTOU window between verification and merge. Both flags are emitted by the script — copy the printed command without modification.) - Tag the merged commit and push:
Wait for the
CIworkflow run on the merge commit to finish green (gh run list --commit <sha>/ bounded polling withgh run view <id> --json status,conclusion): the release's version-gate asserts a completed+success CI run for the tagged SHA and fails closed while it is still running.The tag push triggersgit tag -a vX.Y.Z -m vX.Y.Z git push origin vX.Y.Z
release.yml; the build+publish jobs run from the tag.
The release.yml workflow runs, in order:
- version-gate — synchronized-version check, credential probe, PyPI OIDC probe, source-hygiene gate, and CI-history gate (fails fast).
- build-napi (parallel with build-python) — cross-compiles the addon for all 7 targets.
- build-python (parallel with build-napi) — builds
cp311-abi3wheels for 7 platforms + sdist, runs the readelf linkage gate on Linux legs. - 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) insidenode:22-alpineafter 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. - load-test-musl-arm64 — unguarded job on a native
ubuntu-24.04-armrunner (no QEMU); downloads thenapi-stagedartifact; asserts the arm64 ELF shape with a positive control; runs the arm64 Alpine load test (linux-arm64-musl) onnode:22-alpinewith a run block byte-identical to the x64 step. Skipped when x64 fails (if: !cancelled() && needs.stage-and-verify-napi.result == 'success'); both are re-run together after a fix.publish-cratesneeds this job and requiresneeds.load-test-musl-arm64.result == 'success'in itsif:(PF-047, PF-038, #340). - rehearse-publish-python — pin shape, GHCR manifest,
docker pullandtwine 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). - publish-crates — blocked until
stage-and-verify-napi,load-test-musl-arm64,build-python, ANDrehearse-publish-pythonsucceed.cargo publishmds-core, polls the crates.io index for up to 5 min (bounded, max 20 × 15 s), thenmds-cli. - publish-npm, then publish-python (
publish-pythonneedspublish-npm) — publish npm packages (with provenance) and PyPImarkdown-script(OIDC trusted publishing + PEP 740 attestations,skip-existing: true). - github-release —
gh release createwith generated notes; runs only after all three publish jobs succeed.
publish-testpypi never runs on a tag: it is guarded by inputs.testpypi,
which only a workflow_dispatch can set. On a tag push it reports skipped.
release.yml is a chain of gates followed by irreversible publishes. The name of
the failed job tells you what has already happened: nothing publishes before
Publish to crates.io, and inside that job nothing is irreversible until the
Publish mds-core step succeeds.
| Failed job | Already irreversible | Recovery |
|---|---|---|
Version gate — version mismatch, CI-history gate on a still-running CI run, credential or OIDC probe, source hygiene |
Nothing | Fix the cause. CI still running or just finished: wait for it, then gh run rerun <run-id> --failed. Wrong commit tagged (version mismatch, hygiene): delete the tag (git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z), land the fix via PR, wait for its CI, re-tag. |
build-napi, build-python, Stage + verify platform packages, Alpine load test (linux-arm64-musl), Rehearse PyPI publish (no upload) |
Nothing | Transient (runner, Docker Hub pull, registry outage): gh run rerun <run-id> --failed — re-runs the failed jobs and their dependents; upstream artifacts are reused. Genuine defect: land the fix, bump to the next patch version, tag that. Do not move a tag that a defect was found on. |
Publish to crates.io at Publish mds-core — the v0.4.0 case: HTTP 403 from a revoked token, run 33569514359 |
Nothing | Fix the secret or cause, then gh run rerun <run-id> --failed. |
Publish to crates.io after mds-core is live — index poll timeout, Publish mds-cli failure |
mds-core@X.Y.Z on crates.io (cannot be deleted; cargo yank only hides it from new resolutions) |
Transient: gh run rerun <run-id> --failed — the mds-core step treats "already uploaded" as success and proceeds to mds-cli. Defect in mds-cli: the version is consumed for the whole workspace (one coordinated version); land the fix, bump to the next patch, optionally cargo yank --version X.Y.Z mds-core, and say so in the GitHub Release body. |
Publish to npm — any point |
Both crates on crates.io; zero or more @mdscript/* packages at X.Y.Z on npm |
gh run rerun <run-id> --failed: napi prepublish tolerates E403 for platform packages already published and publish_if_absent skips versions npm view already sees. Do not publish from a workstation — a laptop npm publish carries no OIDC provenance attestation, and that gap is permanent for the version. If a code or workflow change is required for the re-run to succeed: bump to the next patch; npm unpublish is restricted by registry policy and leaves the version number consumed either way, so retire a partial set with npm deprecate rather than trying to remove it. |
Publish to PyPI — the v0.4.1 case: manifest unknown on an annotated-tag-object pin (#350) |
crates.io and the npm packages at X.Y.Z; GitHub Release was skipped |
Transient: gh run rerun <run-id> --failed (skip-existing: true makes the upload re-run safe). Workflow or pin defect: per the tag-immutability rule above, the fix goes to a new commit rather than the tagged one, so land it and bump to the next patch (v0.4.2 was that release), then create the partial version's GitHub Release by hand so the tag is not left bare — gh release create vX.Y.Z --title vX.Y.Z --generate-notes --latest=false — with a body line naming which registries the version reached (v0.4.1 was backfilled this way on 2026-09-04). |
GitHub Release |
Everything is published | gh run rerun <run-id> --failed, or by hand: gh release create vX.Y.Z --title vX.Y.Z --generate-notes (the job itself falls back to gh release edit). |
Across rows:
- Recover with
gh run rerun <run-id> --failed, not by re-pushing the tag while a run is in flight: therelease-<ref>concurrency group hascancel-in-progress: false, a second push queues behind the first, and a cancelled run reads as not-failed to the pre-merge verifier. - Moving a tag is acceptable only while nothing has been published. Once
mds-coreis on crates.io the version belongs to the artifacts built from the tagged commit; re-running later jobs against a different commit would publish npm or PyPI artifacts that do not match what crates.io holds. The next patch version is the recovery. - Run the Post-release checks below per registry after a partial recovery — they are how you confirm the recovery reached every leg that had failed.
- Verify each package on its registry (crates.io, npmjs.com) and that npm shows the provenance attestation.
- Verify
markdown-scripton PyPI and that it shows the PEP 740 attestation. - Smoke test a clean install on a fresh machine/container:
- npm:
npm i @mdscript/mdsthennode -e "import('@mdscript/mds').then(m=>m.init())" - Python:
pip install markdown-scriptthenpython -c "import markdown_script as m; print(m.compile('{{x}}', vars={'x':'ok'}).output)"
- npm:
- Confirm
## [Unreleased]is empty —bump-version.mjsinserts the new version heading beneath it and leaves the heading in place.
- The 7 native napi targets: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-pc-windows-msvc. x86_64-gnu passes napi's --use-napi-cross; aarch64-gnu links with the apt cross gcc; both musl legs cross-compile with
napi build … -x(cargo-zigbuild 0.23.0) — the-xflag makes@napi-rs/cli3.8.6 runcargo zigbuildinstead ofcargo build; cargo-zigbuild 0.23.0 is installed by the SHA-pinnedtaiki-e/install-action(v2.85.10) withfallback: noneplaced BEFORESwatinem/rust-cache(rust-cache deletes~/.cargo/binbinaries on save, so installing after cache would be wiped on a warm hit) and asserted after rust-cache and re-asserted after the build (napi's detector is presence-only —cargo help zigbuild— and silently runs an unpinnedcargo install cargo-zigbuildwhen zigbuild is absent, reverting the pin); zig 0.16.0 via the SHA-pinnedmlugg/setup-zig; cargo-zigbuild 0.23.0 owns the full linker-arg filter (--fix-cortex-a53-843419,--no-undefined-version,-lgcc_sto-lunwind, self-contained musl CRT skip, response files), its CI tests zig 0.16.0, and nothing in 0.23.2–0.23.4 touches x86_64/aarch64 musl — bumping zig OR cargo-zigbuild is a deliberate, paired decision; never set a musl linker export (CARGO_TARGET_*_MUSL_LINKER) — cargo-zigbuild'sadd_env_if_missingyields to a pre-set value and a leftover export would silently revert the migration; the version-keyed wrapper cache (~/.cache/cargo-zigbuild/0.23.0/…) is always fresh per version and is deliberately NOT cached (post-build presence proves cargo-zigbuild 0.23.0 ran in that job, not a prior version from cache); the readelf gate usesALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'with a plantedlibunwind.so.1positive control, and the Alpine load tests are the acceptance instrument for any linkage delta (a NEEDED soname Alpine does not ship is invisible to readelf — only a realdocker run node:22-alpinecatches it). Both musl addons are load-tested onnode:22-alpinebefore anything publishes: the x64 load test is the last step ofstage-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 unguardedload-test-musl-arm64job on a nativeubuntu-24.04-armrunner using thenapi-stagedartifact. The fixture isindex.js+scripts/musl-load-probe.cjs+ only the musl platform package undernode_modules/@mdscript/, so a pass is proof the loader'sisMusl()returned true; a control fixture without the package must fail first (PF-013).publish-cratesblocks on both via itsneeds:list AND itsif: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 onnode:22-alpinecatches that (PF-038 shape). When the x64 load test fails, the arm64 job is skipped (itsif:requiresstage-and-verify-napito succeed) and both tests are re-run together after the fix. The container runs with-w /wbecausenode:22-alpinehas noWORKDIRand mds-core rejects a filesystem-root base directory (#371, surfaced by this gate's first run on PR #370);musl-load-probe.cjsassertsprocess.cwd() === '/w'so a dropped flag fails loudly. - The 8 Python artifacts (7
cp311-abi3wheels + 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 byPyO3/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.soinside 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.
atomic_write_file(crates/mds-cli/src/output.rs) is replace-by-rename: it does not preserve hard links, ACLs, extended attributes (xattrs), or owner/group of the original file (permission bits are restored on Unix). This applies tomds fmt,mds lint --fix, and — since #227 —mds build/mds watchcompiled outputs and.mapsidecars (source rewrites fsync before the rename; compiled outputs and sidecars rely on the rename alone). The user-facing contract lives in spec §7.2 ("Output writing") andSECURITY.md; hard-link preservation is won't-fix by construction, the rest is not planned (#226).Swatinem/rust-cachecomputes its key asv0-rust[-<key>]-<job>-<runner.os>-<runner.arch>-<envhash>-<lockhash>; the env hash coversrustc -vV(the HOST triple) plusCARGO*/CC*/CFLAGSenv. The cross TARGET is not in it, so withoutkey:all four ubuntu legs inbuild-napiand both macOS legs share one blob. Both matrix jobs carry per-leg keys:build-napiuseskey: ${{ matrix.settings.target }}(#352, PF-041);build-pythonuseskey: ${{ matrix.target }}-${{ matrix.manylinux }}(#347) so a containerised Linux leg never restores host-built build scripts or proc-macro.sofiles written by another leg.publish-cratesandpublish-npmare single-leg and use the automatic key. Spec S20 inscripts/__test__/release-auth-probe.spec.mjspins this — droppingwith: key:from a matrix job's rust-cache step causes S20 to failVersion gate. Validation: two runs in the SAME cache scope — first run showsNo cache found.; second showsRestored from cache key "v0-rust-<target>-build-napi-…" full match: true.for every leg with the readelf gates green.pull_requestcaches live underrefs/pull/N/mergeand are invisible to a branch dispatch, so warm evidence comes from a second dispatch on the same branch (or a PR-run rerun), never from a dispatch that follows a PR run.