-
Notifications
You must be signed in to change notification settings - Fork 2
1492 lines (1457 loc) · 82.8 KB
/
Copy pathrelease.yml
File metadata and controls
1492 lines (1457 loc) · 82.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Release
# Three entry points:
# * push a version tag (v*) -> full coordinated release
# * pull_request touching release-surface paths -> rehearse only, no publish
# * workflow_dispatch -> dry run (build + verify, no publish)
# testpypi: true -> same + publish to TestPyPI (opt-in leg)
#
# The `paths:` list IS the release surface. Publish jobs stay dark on PRs
# (refs/pull/N/merge fails the refs/tags/v tag guard -> skipped, tolerated by
# 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 six paths below still needs
# the manual workflow_dispatch dry-run.
on:
push:
tags:
- "v*"
pull_request:
paths:
- '.github/workflows/release.yml'
- '.github/actions/**'
- 'crates/mds-napi/**'
- 'crates/mds-python/**'
- 'scripts/verify-napi-names.mjs'
- 'scripts/musl-load-probe.cjs'
workflow_dispatch:
inputs:
testpypi:
description: 'Publish to TestPyPI (rehearsal; skip-existing is on)'
type: boolean
default: false
permissions:
contents: read
# cancel-in-progress: false is LOAD-BEARING: setting true would cancel a run
# mid-sequence (e.g., between cargo publish and npm publish). A cancelled run
# reads as "not failing" (PF-017), which would let --admin merge bypass the CI
# gate and could manufacture the partial-publish state reliability-05 guards against.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
# ---------------------------------------------------------------------------
# D1 — version-consistency gate. Cheap; fails fast before any build/publish.
# ---------------------------------------------------------------------------
version-gate:
name: Version gate
runs-on: ubuntu-latest
permissions:
contents: read
checks: read # commit check/status reads
actions: read # CI workflow runs + their jobs, for the CI-history step
id-token: write # OIDC for PyPI trusted publisher probe (F5, security-08)
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v7
with: { node-version: 22, registry-url: "https://registry.npmjs.org" }
- name: "Verify publish credentials before irreversible steps (security-08)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
CARGO_REG_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: |
# Fail closed on fork and Dependabot pull_request events: these receive
# no repository secrets and no id-token: write (read-only GITHUB_TOKEN),
# so the credential probes cannot succeed there. A maintainer-authored PR
# or a manual `gh workflow run release.yml --ref <branch>` is the remedy.
if [ -z "$NODE_AUTH_TOKEN" ] && [ "${GITHUB_EVENT_NAME}" = "pull_request" ] \
&& { [ "${GITHUB_ACTOR}" = "dependabot[bot]" ] || [ "${IS_FORK}" = "true" ]; }; then
echo "::error::No Actions secrets on this run: pull_request runs from Dependabot or a fork are treated as fork PRs (read-only token, no Actions secrets, no id-token: write), so the release credentials cannot be verified here. Do NOT merge on these checks. Supersede with a maintainer-authored PR, or run: gh workflow run release.yml --ref ${GITHUB_HEAD_REF}"
exit 1
fi
# Guard: npm token must be non-empty. A missing or revoked secret interpolates
# to an empty string in GitHub Actions — not an error — so this must be checked
# explicitly before cargo publish runs and makes crates.io publish irreversible.
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "::error::NPM_TOKEN is empty or unset — npm publish would fail AFTER cargo publish (irreversible). Set the NPM_TOKEN repo secret. (security-08)"
exit 1
fi
# Guard: cargo token non-empty. That is the strongest check crates.io allows for an
# API token (no read-only endpoint accepts a scoped token; #345, RELEASING.md "credential probe").
if [ -z "$CARGO_REG_TOKEN" ]; then
echo "::error::CARGO_REGISTRY_TOKEN is empty or unset — cargo publish would fail. Set the CARGO_REGISTRY_TOKEN repo secret. (security-08)"
exit 1
fi
# Verify npm authentication: whoami proves the token is accepted by the registry.
# NOTE: this verifies authentication only, NOT publish rights to @mdscript scope.
# A read-only or wrongly-scoped token passes this check but fails at publish time.
WHO=$(npm whoami --registry https://registry.npmjs.org 2>&1) || {
echo "::error::npm whoami failed — NPM_TOKEN may be revoked or expired: ${WHO}"
exit 1
}
echo "npm credentials verified: token resolves to npm user '${WHO}'"
# PyPI OIDC trusted-publisher probe (F5, security-08). The mint-token
# exchange is free and idempotent — the minted token is simply unused here.
# NOT tag-guarded: this step must run in the workflow_dispatch dry run
# (PF-039) so a misconfigured or expired trusted publisher is caught BEFORE
# any irreversible crates.io publish. version-gate has id-token: write above.
- name: "Verify PyPI trusted publisher before irreversible steps (security-08)"
shell: bash
run: |
set -euo pipefail
RESP=$(curl -sS "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=pypi" \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}")
TOK=$(echo "$RESP" | jq -r '.value')
CODE=$(curl -sS -o /tmp/mint.json -w '%{http_code}' \
-X POST https://pypi.org/_/oidc/mint-token \
-d "{\"token\":\"${TOK}\"}")
[ "$CODE" = "200" ] || {
echo "::error::PyPI OIDC mint failed (HTTP $CODE) — trusted publisher missing, expired, or mismatched. Fix BEFORE tagging; crates.io is irreversible."
jq -r '.message // .' /tmp/mint.json; exit 1; }
echo "PyPI trusted publisher OK"
# The pypa/gh-action-pypi-publish pin is validated in rehearse-publish-python
# (pin shape, GHCR manifest, docker pull, twine check — each with a positive
# control). publish-crates needs that job, so a bad pin still aborts the
# release before the irreversible crates.io write (PF-040, PF-023).
- name: "Assert synchronized versions, no file: refs"
run: node scripts/verify-versions.mjs
# #288: Source-hygiene gate — also runs on tag pushes via this job.
# ci.yml does not run on tag pushes, so these two steps ensure the full
# gate (scanner + positive-control suite) is enforced at release time.
# test:gates enforces a minimum spec count (>=3) so the suite cannot
# be silently skipped on the release path (ADR-009/PF-013).
# Uses the same Node 22 install above.
- name: "Assert no hazardous codepoints in tracked source"
run: node scripts/verify-no-control-bytes.mjs
- name: "Run positive-control and class-completeness suite"
run: npm run test:gates
# reliability-12: assert the SHA being released went green in CI before
# any irreversible publish starts. Runs on tag push (verifies the tagged
# commit) and workflow_dispatch (verifies the dispatched ref's HEAD).
# Step-skipped on pull_request: github.sha is the ephemeral merge commit,
# not the branch head — see the if: guard on this step and the sibling
# notice step below. Cancelled, failed, in-progress and absent runs all
# fail closed (PF-017; PF-013: absence is not success).
# Cannot reuse scripts/verify-pr-checks.mjs here - that script requires a
# PR number, and tag pushes reference a commit, not a PR.
# Branch protection is NOT read: /branches/main/protection needs
# repository administration rights, which the workflow token can never
# hold (it returned HTTP 403 on the v0.4.0 tag push). ADR-013's
# required-context count is mirrored into MIN_CI_JOBS below instead.
- name: "Assert tagged SHA has green CI history (PF-017)"
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
SHA="${{ github.sha }}"
REPO="${{ github.repository }}"
# MIN_CI_JOBS mirrors the 15 required contexts in main's branch
# protection and EXPECTED_CONTEXTS in scripts/verify-pr-checks.mjs.
# ADR-013: adding a CI job is a multi-place change - bump this floor
# when the required set grows.
MIN_CI_JOBS=15
# Bounded: one page of 50 is far above the number of CI runs a single
# commit can accumulate.
if ! RUNS=$(gh api \
"/repos/${REPO}/actions/workflows/ci.yml/runs?head_sha=${SHA}&per_page=50" \
--jq '.workflow_runs'); then
echo "::error::CI workflow-runs API error for ${SHA}"
exit 1
fi
TOTAL=$(echo "$RUNS" | jq 'length')
if [ "$TOTAL" -eq 0 ]; then
echo "::error::No CI workflow run for ${SHA} (PF-013: absence is not success)"
exit 1
fi
echo "${TOTAL} CI workflow run(s) found for ${SHA}"
# A commit whose CI has not finished is indeterminate, not green.
COMPLETED=$(echo "$RUNS" | jq '[.[] | select(.status == "completed")] | length')
PENDING=$(echo "$RUNS" | jq '[.[]
| select(.status == "queued" or .status == "in_progress")] | length')
if [ "$COMPLETED" -eq 0 ] && [ "$PENDING" -gt 0 ]; then
echo "::error::CI for ${SHA} is still running - wait for it to complete, then re-run (PF-017)"
exit 1
fi
# PF-017: only completed+success counts. Take the most recent one.
RUN_ID=$(echo "$RUNS" | jq -r '[.[]
| select(.status == "completed" and .conclusion == "success")]
| max_by(.id) | .id // empty')
if [ -z "$RUN_ID" ]; then
echo "$RUNS" | jq -r '.[]
| " \(.id) event=\(.event) status=\(.status) conclusion=\(.conclusion // "null")"'
echo "::error::No completed+success CI run for ${SHA} (cancelled/failed/in_progress are not success - PF-017)"
exit 1
fi
RUN_URL=$(echo "$RUNS" | jq -r --argjson id "$RUN_ID" \
'.[] | select(.id == $id) | .html_url')
RUN_EVENT=$(echo "$RUNS" | jq -r --argjson id "$RUN_ID" \
'.[] | select(.id == $id) | .event')
# The jobs list of that run is the authority on what actually ran.
# Bounded: one page of 100 jobs.
if ! JOBS=$(gh api \
"/repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" \
--jq '.jobs'); then
echo "::error::CI jobs API error for run ${RUN_ID}"
exit 1
fi
BAD=$(echo "$JOBS" | jq -r '[.[]
| select(.status != "completed" or .conclusion != "success")]
| .[] | " \(.name): \(.status)/\(.conclusion // "null")"')
if [ -n "$BAD" ]; then
echo "$BAD"
echo "::error::CI run ${RUN_ID} has job(s) that are not completed+success (PF-017)"
exit 1
fi
N=$(echo "$JOBS" | jq 'length')
if [ "$N" -lt "$MIN_CI_JOBS" ]; then
echo "::error::CI run ${RUN_ID} has only ${N} jobs; expected >= ${MIN_CI_JOBS} (ADR-013 required-context count) - a gutted workflow is not a pass"
exit 1
fi
echo "CI run ${RUN_ID} (event=${RUN_EVENT}): ${RUN_URL}"
echo " ${N} job(s), all completed+success (floor: ${MIN_CI_JOBS})"
echo "CI history OK for ${SHA}"
- name: "CI-history check skipped on pull_request (step-level guard — see ADR-013 amendment)"
if: github.event_name == 'pull_request'
run: |
echo "::notice::CI-history gate skipped for pull_request events — only required on tag push and workflow_dispatch dry runs."
# ---------------------------------------------------------------------------
# A6 — cross-compile the native addon for all 7 targets.
# Linux legs: x86_64-gnu uses napi's --use-napi-cross (gnu sysroot);
# aarch64-gnu links with the apt cross gcc: --use-napi-cross regressed on
# aarch64 in 4e88ebd (the then macOS-generated lockfile did not resolve
# @napi-rs/tar-linux-x64-gnu). Not re-tested since; the apt gcc works.
# BOTH musl legs cross-compile with napi build ... -x (cargo-zigbuild 0.23.0,
# installed by the SHA-pinned install-action BEFORE rust-cache and asserted
# after it) because --use-napi-cross has no musl toolchain and napi forces
# -C target-feature=-crt-static on musl, so the host gcc would silently link
# glibc (v0.1.0-v0.3.0 shipped a glibc-linked linux-x64-musl addon); -x cannot
# be combined with --use-napi-cross; the readelf gate (with its NEEDED allowlist)
# and the Alpine load tests (#340) are the acceptance gates.
# macOS x86_64 cross-links from the arm64 host via the installed target.
# `--no-js` preserves the hand-written index.js loader (index.d.ts still
# regenerates).
# ---------------------------------------------------------------------------
build-napi:
name: Build napi (${{ matrix.settings.target }})
needs: [version-gate]
if: ${{ !cancelled() && needs.version-gate.result == 'success' }}
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: aarch64-apple-darwin
build: napi build --platform --release --target aarch64-apple-darwin --no-js
- host: macos-latest
target: x86_64-apple-darwin
build: napi build --platform --release --target x86_64-apple-darwin --no-js
- host: ubuntu-latest
target: x86_64-unknown-linux-gnu
build: napi build --platform --release --target x86_64-unknown-linux-gnu --use-napi-cross --no-js
- host: ubuntu-latest
target: x86_64-unknown-linux-musl
# napi -x delegates to cargo zigbuild; cargo-zigbuild owns the linker-arg filter
# (-znostart-stop-gc, self-contained musl CRT skip, response files, etc.) internally
# in src/zig.rs and sets no CARGO_TARGET_*_LINKER.
# napi still injects -C target-feature=-crt-static (dynamic musl, libc.so stays).
# The hand-written zig-cc script and CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER
# export are deleted (#339). Never reintroduce a musl linker export here.
build: napi build --platform --release --target x86_64-unknown-linux-musl --no-js -x
use-zig: true
- host: ubuntu-latest
target: aarch64-unknown-linux-gnu
build: napi build --platform --release --target aarch64-unknown-linux-gnu --no-js
setup: |
sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- host: ubuntu-latest
target: aarch64-unknown-linux-musl
# napi -x delegates to cargo zigbuild; cargo-zigbuild owns the linker-arg filter
# (--fix-cortex-a53-843419, -znostart-stop-gc, -lgcc_s->-lunwind, etc.) internally
# in src/zig.rs and sets no CARGO_TARGET_*_LINKER.
# napi still injects -C target-feature=-crt-static (dynamic musl, libc.so stays).
# The hand-written zig-cc script and CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER
# export are deleted (#339). Never reintroduce a musl linker export here.
build: napi build --platform --release --target aarch64-unknown-linux-musl --no-js -x
use-zig: true
- host: windows-latest
target: x86_64-pc-windows-msvc
build: napi build --platform --release --target x86_64-pc-windows-msvc --no-js
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
# Install the pinned cargo-zigbuild BEFORE rust-cache (#339): napi's -x path only checks that
# `cargo help zigbuild` succeeds and otherwise runs an unpinned `cargo install cargo-zigbuild`
# (PF-038 shape: existence, never identity); and rust-cache deletes from ~/.cargo/bin every binary
# that pre-dated it before saving, so installing first keeps the pin OUT of the cache. Composite
# action -> SHA pin is correct (PF-040 is about Docker trampolines). fallback: none forbids a
# silent cargo-binstall/source-build substitution.
- name: Install cargo-zigbuild (pinned)
if: matrix.settings.use-zig
uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild@0.23.0
fallback: none
# Per-leg cache key (PF-041, #352). The automatic key is job id + runner
# os/arch + a hash of the rustc HOST triple, env and lockfiles; the cross
# target is not part of it, so without `key:` all four ubuntu legs and both
# macOS legs restore ONE blob (run 34065573775: every Linux leg restored
# v0-rust-build-napi-Linux-x64-...). Keying on the target keeps each leg's
# target/<triple>/ artifacts and host-built build scripts to itself. The
# host is already encoded (runner.os/arch + rustc host hash), so only the
# target is added. Intentional; must survive rust-cache version bumps --
# spec S20 in scripts/__test__/release-auth-probe.spec.mjs pins it.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.settings.target }}
- name: Install zig
if: matrix.settings.use-zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.16.0
- name: Assert cargo-zigbuild is the pinned version
if: matrix.settings.use-zig
run: |
set -euo pipefail
EXPECTED=0.23.0
# Positive control (PF-013): the matcher must REJECT a wrong version.
if printf 'cargo-zigbuild 9.9.9\n' | grep -Fqx "cargo-zigbuild ${EXPECTED}"; then echo "::error::positive control FAILED - matcher accepted 9.9.9"; exit 1; fi
echo "positive control OK: the matcher rejects a version that is not the pin"
# Probe the BINARY, never the cargo subcommand. cargo-zigbuild's clap enum sets
# version on the top-level command only and never sets propagate_version, so
# `cargo zigbuild --version` is a parse error - "unexpected argument '--version'
# found", exit 2 - which under set -e would fail this step on every musl leg.
# `cargo-zigbuild --version` prints exactly "cargo-zigbuild <version>" (#339).
ACTUAL=$(cargo-zigbuild --version); echo "cargo-zigbuild --version -> ${ACTUAL}"
printf '%s\n' "${ACTUAL}" | grep -Fqx "cargo-zigbuild ${EXPECTED}" || { echo "::error::cargo-zigbuild is '${ACTUAL}', expected ${EXPECTED} (stale restore or an unpinned install; the linker-arg filter this leg depends on changed, #339)"; exit 1; }
# napi's own predicate (detectCargoBinary): if this fails, napi installs an unpinned copy mid-build.
cargo help zigbuild >/dev/null 2>&1 || { echo "::error::cargo help zigbuild failed - napi would cargo install an unpinned cargo-zigbuild (#339)"; exit 1; }
echo "cargo help zigbuild OK - napi will use the pinned binary"
- name: Setup cross-compilation tools
if: matrix.settings.setup
run: ${{ matrix.settings.setup }}
- run: npm ci
- name: Verify no lingering musl linker export or wrapper (no-op detector)
if: matrix.settings.use-zig
run: |
set -euo pipefail
# Resolve the wrapper cache exactly the way cargo-zigbuild does: its cache_dir()
# (src/zig.rs) is dirs::cache_dir() - $XDG_CACHE_HOME when set, else $HOME/.cache -
# with the crate name and version appended. Hard-coding $HOME/.cache would make
# this absence check vacuous on any runner that sets XDG_CACHE_HOME (PF-013).
CZB_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0"
[ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || { echo "::error::CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER is set - cargo-zigbuild's add_env_if_missing would yield to it and silently revert the migration (#339)"; exit 1; }
[ -z "${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER:-}" ] || { echo "::error::CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER is set - cargo-zigbuild's add_env_if_missing would yield to it and silently revert the migration (#339)"; exit 1; }
if compgen -G '/tmp/zig-cc-*' > /dev/null; then echo "::error::hand-written zig-cc script present under /tmp - the migration is not in effect (#339)"; exit 1; fi
[ ! -d "$CZB_CACHE" ] || { echo "::error::a cargo-zigbuild 0.23.0 wrapper cache pre-exists at $CZB_CACHE - it must be generated by THIS build to count as proof (#339)"; exit 1; }
echo "no-op detector OK: no musl linker export, no wrapper, no pre-existing cargo-zigbuild cache at $CZB_CACHE"
- name: Build addon
working-directory: crates/mds-napi
run: npx ${{ matrix.settings.build }}
# ~/.cache/cargo-zigbuild is NOT added to rust-cache cache-directories on purpose:
# its presence after the build is the proof that cargo-zigbuild 0.23.0 ran in THIS job
# (not a restored cache from a prior run). The post-build step below asserts it.
- name: Assert cargo-zigbuild 0.23.0 wrappers were generated
if: matrix.settings.use-zig
run: |
set -euo pipefail
# Same resolution rule as the no-op detector (cargo-zigbuild's cache_dir(), src/zig.rs).
CZB_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/cargo-zigbuild/0.23.0"
# cargo-zigbuild writes zigcc-<target>-<hash>.sh into <cache>/wrappers/<exe-hash>/,
# so the search is recursive. Capture rather than pipe into grep: a missing directory
# then reports as an empty result with a real error message, and there is no
# pipefail/SIGPIPE interaction with a short-circuiting reader.
WRAPPERS=$(find "$CZB_CACHE" -name 'zigcc-*' 2>/dev/null || true)
[ -n "$WRAPPERS" ] || { echo "::error::no cargo-zigbuild 0.23.0 wrappers under $CZB_CACHE - cargo zigbuild did not run in this job (#339)"; exit 1; }
echo "wrappers generated by this build:"; printf '%s\n' "$WRAPPERS"
ACTUAL=$(cargo-zigbuild --version); printf '%s\n' "${ACTUAL}" | grep -Fqx "cargo-zigbuild 0.23.0" || { echo "::error::post-build: cargo-zigbuild is '${ACTUAL}', expected 0.23.0 (napi may have replaced it mid-build, #339)"; exit 1; }
echo "cargo-zigbuild 0.23.0 wrappers generated by this build"
- name: Assert musl artifact links musl, not glibc
if: ${{ endsWith(matrix.settings.target, '-linux-musl') }}
working-directory: crates/mds-napi
run: |
# Detector self-test: the glibc pattern must fire on a planted glibc
# NEEDED line, so a silently broken pattern cannot read as "clean".
GLIBC_RE='libc\.so\.6|ld-linux'
printf ' 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]\n' \
| grep -Eq "$GLIBC_RE" \
|| { echo "::error::glibc detector self-test failed to fire"; exit 1; }
shopt -s nullglob
set -- *.node
[ "$#" -eq 1 ] || { echo "::error::expected exactly one .node artifact, found $#${1:+: $*}"; exit 1; }
node_file=$1
readelf -d "$node_file" > /tmp/dyn.txt
# Non-vacuity guard: readelf must have produced NEEDED entries at all.
grep -q 'NEEDED' /tmp/dyn.txt || { echo "::error::no NEEDED entries parsed from $node_file"; exit 1; }
if grep -Eq "$GLIBC_RE" /tmp/dyn.txt; then
echo "::error::$node_file is glibc-linked; expected musl (libc.so)"
cat /tmp/dyn.txt
exit 1
fi
# Positive assertion: a musl cdylib links musl's libc by its
# soname libc.so.
grep -q 'Shared library: \[libc\.so\]' /tmp/dyn.txt || {
echo "::error::$node_file does not link musl libc.so"
cat /tmp/dyn.txt
exit 1
}
# NEEDED allowlist: a dynamic musl cdylib should need musl's libc.so and
# nothing else. libgcc_s.so.1 is tolerated because node:22-alpine ships it
# and the unwind ABI can surface as a DT_NEEDED depending on how the
# unwinder is linked; libunwind.so.1 is NOT tolerated - cargo-zigbuild
# 0.23.0 rewrites -lgcc_s to -lunwind (src/zig.rs) and zig links its own
# libunwind into the artifact, so a libunwind.so.1 DT_NEEDED would mean the
# addon needs a shared object Alpine does not ship and would fail to dlopen
# at runtime, which readelf alone cannot see (PF-038, PF-013).
ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'
# Positive control (PF-013): the allowlist must REJECT libunwind.so.1.
if ! printf ' 0x0000000000000001 (NEEDED) Shared library: [libunwind.so.1]\n' \
| grep '(NEEDED)' | grep -vqE "\[(${ALLOWED_NEEDED})\]"; then
echo "::error::positive control FAILED - the allowlist accepted libunwind.so.1"; exit 1
fi
echo "positive control OK: the NEEDED allowlist rejects libunwind.so.1"
if grep '(NEEDED)' /tmp/dyn.txt | grep -vqE "\[(${ALLOWED_NEEDED})\]"; then
echo "::error::unexpected NEEDED entry: $(grep '(NEEDED)' /tmp/dyn.txt | grep -vE "\[(${ALLOWED_NEEDED})\]" | tr -s ' ') - node:22-alpine may not ship it; this is where the Alpine load test (#340) would fail at runtime"
exit 1
fi
echo "NEEDED entries (all within allowlist):"
grep '(NEEDED)' /tmp/dyn.txt
cat /tmp/dyn.txt
- uses: actions/upload-artifact@v7
with:
name: bindings-${{ matrix.settings.target }}
path: crates/mds-napi/*.node
if-no-files-found: error
# ---------------------------------------------------------------------------
# A3/A7/D2 — stage the per-platform npm packages from the built .node files,
# then run the CRITICAL name<->loader gate. Runs on BOTH tag push and the
# workflow_dispatch dry run; it never publishes. The publish job consumes the
# exact staged tree this job uploads.
# ---------------------------------------------------------------------------
stage-and-verify-napi:
name: Stage + verify platform packages
needs: [build-napi]
if: ${{ !cancelled() && needs.build-napi.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Download all .node artifacts
uses: actions/download-artifact@v8
with:
path: crates/mds-napi/artifacts
pattern: bindings-*
merge-multiple: true
- name: Stage per-platform packages
working-directory: crates/mds-napi
run: |
npx napi create-npm-dirs
npx napi artifacts --output-dir .
- name: Copy LICENSE into each platform package
run: for d in crates/mds-napi/npm/*/; do cp LICENSE "$d"; done
- name: A3 — name <-> loader verification gate
run: node scripts/verify-napi-names.mjs
- name: Upload staged napi tree
uses: actions/upload-artifact@v7
with:
name: napi-staged
path: |
crates/mds-napi/npm/**
crates/mds-napi/*.node
if-no-files-found: error
# readelf proves ELF metadata, not dlopen, so it is insufficient to confirm
# the addon loads on Alpine musl (PF-038). The fixture shape IS the assertion
# (PF-013): index.js + probe + ONLY the musl platform package, no .node beside
# index.js (loader candidate 2 would short-circuit), gnu package absent — so
# a pass is only possible if the loader's isMusl() returned true. This step
# never runs from crates/mds-napi/ because napi artifacts writes every .node
# into the crate root; the staged npm/ tree is the fixture source.
# This step has no local counterpart (PF-036). publish-crates blocks on it.
- name: "Alpine load test (linux-x64-musl)"
shell: bash
env:
ALPINE_IMAGE: node:22-alpine
PLATFORM: linux-x64-musl
ARCHKEY: linux-x64
NPM_DIR: crates/mds-napi/npm
run: |
set -euo pipefail
shopt -s nullglob
FIX="${RUNNER_TEMP}/alpine-load-${PLATFORM}"; CTRL="${FIX}-control"
rm -rf "${FIX}" "${CTRL}"; mkdir -p "${FIX}/node_modules/@mdscript"
cp crates/mds-napi/index.js "${FIX}/index.js"
cp scripts/musl-load-probe.cjs "${FIX}/probe.cjs"
cp -R "${NPM_DIR}/${PLATFORM}" "${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}"
# Fixture shape IS the assertion (PF-013): index.js + probe + ONE platform package.
bin="${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}/mds-napi.${PLATFORM}.node"
[ -s "${bin}" ] || { echo "::error::staged package has no ${bin##*/} (did napi artifacts skip this leg?)"; exit 1; }
stray=("${FIX}"/*.node)
[ "${#stray[@]}" -eq 0 ] || { echo "::error::.node beside index.js: ${stray[*]} (loader candidate 2 would short-circuit the package path)"; exit 1; }
[ ! -e "${FIX}/node_modules/@mdscript/mds-napi-${ARCHKEY}-gnu" ] || { echo "::error::gnu package present in the fixture; a false isMusl() could pass"; exit 1; }
scopes=("${FIX}"/node_modules/*); pkgs=("${FIX}"/node_modules/@mdscript/*)
{ [ "${#scopes[@]}" -eq 1 ] && [ "${#pkgs[@]}" -eq 1 ]; } || { echo "::error::fixture must hold exactly one package (scopes=${#scopes[@]} packages=${#pkgs[@]})"; exit 1; }
cp -R "${FIX}" "${CTRL}"; rm -rf "${CTRL}/node_modules"
# GitHub-hosted runners are exempt from Docker Hub's pull limit for public images; a mirror
# would trade a documented exemption for a tighter quota (#340). Bounded retry (PF-013 shape).
PULLED=0
for i in 1 2 3; do
if timeout 300 docker pull --quiet "${ALPINE_IMAGE}"; then PULLED=1; break; fi
echo "::notice::docker pull attempt ${i}/3 failed for ${ALPINE_IMAGE}; retrying in 10 s"; sleep 10
done
[ "${PULLED}" -eq 1 ] || { echo "::error::docker pull failed for ${ALPINE_IMAGE} after 3 attempts"; exit 1; }
# Go template braces on the next line, not an Actions expression (Actions interpolates only
# the dollar form), same as the rehearsal job's digest print.
docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}'
# node:22-alpine sets no WORKDIR; the default cwd is /; mds-core rejects a filesystem-root
# base directory (#371, found by this gate's first run); the workdir flag (-w) is set to /w
# (fixture dir, the shape any real non-root cwd has); probe asserts cwd; dropped flag exits 1.
# Positive control FIRST (PF-013): without the package the SAME probe must FAIL, and it must
# fail on loader candidate 3 for the MUSL KEY - that failure is also the isMusl() proof.
if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \
-v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then
echo "::error::positive control FAILED - the probe passed WITHOUT the ${PLATFORM} package, so a green run proves nothing (PF-013)"
printf '%s\n' "${CTRL_LOG}"; exit 1
fi
printf '%s\n' "${CTRL_LOG}"
for needle in 'Failed to load mds-napi native binding for' "mds-napi.${PLATFORM}.node" "@mdscript/mds-napi-${PLATFORM}"; do
printf '%s\n' "${CTRL_LOG}" | grep -qF -- "${needle}" || { echo "::error::control failed for the wrong reason: missing '${needle}'"; exit 1; }
done
if printf '%s\n' "${CTRL_LOG}" | grep -q -- "${ARCHKEY}-gnu"; then
echo "::error::the loader computed the gnu key on Alpine - isMusl() returned false"; exit 1
fi
echo "positive control OK: load failed without the musl package, on the musl key"
timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \
-v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}"
# ---------------------------------------------------------------------------
# Alpine musl load test on native arm64 — docker run from host job (ADR-013).
# Public repos get free native ubuntu-24.04-arm runners (no ubuntu-latest-arm
# label). Job-level container: is rejected by the runner on arm64: "JavaScript
# Actions in Alpine containers are only supported on x64 Linux runners". QEMU
# emulation is disqualified on reliability: Node segfaults under arm64 QEMU
# emulation (actions/runner-images#11471). Test is docker run from the host.
# This job is unguarded BY DESIGN (ADR-013): it reaches success on every PR and
# dispatch run, so it is NOT in TIER_B_EXPECTED_SKIPPED (M10c keeps 5 guarded
# jobs) and deliberately NOT in RELEASE_SURFACE_CONTEXTS. Spec S21 pins it.
# publish-crates lists this job in both its needs and its if-conjuncts (PF-047).
# ---------------------------------------------------------------------------
load-test-musl-arm64:
name: Alpine load test (linux-arm64-musl)
needs: [stage-and-verify-napi]
if: ${{ !cancelled() && needs.stage-and-verify-napi.result == 'success' }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Assert native arm64 runner
run: |
[ "$(uname -m)" = aarch64 ] || { echo "::error::expected an aarch64 runner (label ubuntu-24.04-arm), got $(uname -m)"; exit 1; }
docker version --format 'docker server {{.Server.Version}}'
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
- name: Restore staged napi tree
uses: actions/download-artifact@v8
with:
name: napi-staged
path: staged
- name: Assert the staged tree is complete and the arm64 binary is AArch64
run: |
set -euo pipefail
n=$(find staged/npm -mindepth 1 -maxdepth 1 -type d | wc -l)
[ "$n" -eq 7 ] || { echo "::error::expected 7 platform dirs in staged/npm, found $n"; exit 1; }
arm=staged/npm/linux-arm64-musl/mds-napi.linux-arm64-musl.node
x64=staged/npm/linux-x64-musl/mds-napi.linux-x64-musl.node
{ [ -s "$arm" ] && [ -s "$x64" ]; } || { echo "::error::musl binaries missing from the staged tree"; exit 1; }
# Positive control (PF-013): the x86_64 sibling must NOT read as AArch64.
if readelf -h "$x64" | grep -q 'Machine:.*AArch64'; then echo "::error::positive control FAILED - x86_64 binary read as AArch64"; exit 1; fi
echo "positive control OK: the x86_64 sibling is not AArch64"
readelf -h "$arm" | grep -q 'Machine:.*AArch64' || { echo "::error::$arm is not an AArch64 ELF"; readelf -h "$arm"; exit 1; }
- name: "Alpine load test (linux-arm64-musl)"
shell: bash
env:
ALPINE_IMAGE: node:22-alpine
PLATFORM: linux-arm64-musl
ARCHKEY: linux-arm64
NPM_DIR: staged/npm
run: |
set -euo pipefail
shopt -s nullglob
FIX="${RUNNER_TEMP}/alpine-load-${PLATFORM}"; CTRL="${FIX}-control"
rm -rf "${FIX}" "${CTRL}"; mkdir -p "${FIX}/node_modules/@mdscript"
cp crates/mds-napi/index.js "${FIX}/index.js"
cp scripts/musl-load-probe.cjs "${FIX}/probe.cjs"
cp -R "${NPM_DIR}/${PLATFORM}" "${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}"
# Fixture shape IS the assertion (PF-013): index.js + probe + ONE platform package.
bin="${FIX}/node_modules/@mdscript/mds-napi-${PLATFORM}/mds-napi.${PLATFORM}.node"
[ -s "${bin}" ] || { echo "::error::staged package has no ${bin##*/} (did napi artifacts skip this leg?)"; exit 1; }
stray=("${FIX}"/*.node)
[ "${#stray[@]}" -eq 0 ] || { echo "::error::.node beside index.js: ${stray[*]} (loader candidate 2 would short-circuit the package path)"; exit 1; }
[ ! -e "${FIX}/node_modules/@mdscript/mds-napi-${ARCHKEY}-gnu" ] || { echo "::error::gnu package present in the fixture; a false isMusl() could pass"; exit 1; }
scopes=("${FIX}"/node_modules/*); pkgs=("${FIX}"/node_modules/@mdscript/*)
{ [ "${#scopes[@]}" -eq 1 ] && [ "${#pkgs[@]}" -eq 1 ]; } || { echo "::error::fixture must hold exactly one package (scopes=${#scopes[@]} packages=${#pkgs[@]})"; exit 1; }
cp -R "${FIX}" "${CTRL}"; rm -rf "${CTRL}/node_modules"
# GitHub-hosted runners are exempt from Docker Hub's pull limit for public images; a mirror
# would trade a documented exemption for a tighter quota (#340). Bounded retry (PF-013 shape).
PULLED=0
for i in 1 2 3; do
if timeout 300 docker pull --quiet "${ALPINE_IMAGE}"; then PULLED=1; break; fi
echo "::notice::docker pull attempt ${i}/3 failed for ${ALPINE_IMAGE}; retrying in 10 s"; sleep 10
done
[ "${PULLED}" -eq 1 ] || { echo "::error::docker pull failed for ${ALPINE_IMAGE} after 3 attempts"; exit 1; }
# Go template braces on the next line, not an Actions expression (Actions interpolates only
# the dollar form), same as the rehearsal job's digest print.
docker image inspect "${ALPINE_IMAGE}" --format 'pulled digests: {{json .RepoDigests}}'
# node:22-alpine sets no WORKDIR; the default cwd is /; mds-core rejects a filesystem-root
# base directory (#371, found by this gate's first run); the workdir flag (-w) is set to /w
# (fixture dir, the shape any real non-root cwd has); probe asserts cwd; dropped flag exits 1.
# Positive control FIRST (PF-013): without the package the SAME probe must FAIL, and it must
# fail on loader candidate 3 for the MUSL KEY - that failure is also the isMusl() proof.
if CTRL_LOG=$(timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \
-v "${CTRL}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}" 2>&1); then
echo "::error::positive control FAILED - the probe passed WITHOUT the ${PLATFORM} package, so a green run proves nothing (PF-013)"
printf '%s\n' "${CTRL_LOG}"; exit 1
fi
printf '%s\n' "${CTRL_LOG}"
for needle in 'Failed to load mds-napi native binding for' "mds-napi.${PLATFORM}.node" "@mdscript/mds-napi-${PLATFORM}"; do
printf '%s\n' "${CTRL_LOG}" | grep -qF -- "${needle}" || { echo "::error::control failed for the wrong reason: missing '${needle}'"; exit 1; }
done
if printf '%s\n' "${CTRL_LOG}" | grep -q -- "${ARCHKEY}-gnu"; then
echo "::error::the loader computed the gnu key on Alpine - isMusl() returned false"; exit 1
fi
echo "positive control OK: load failed without the musl package, on the musl key"
timeout 600 docker run --rm --network none --pull=never -w /w -e NODE_PATH= -e NODE_OPTIONS= \
-v "${FIX}:/w:ro" "${ALPINE_IMAGE}" node /w/probe.cjs "${PLATFORM}"
# ---------------------------------------------------------------------------
# Python wheels + sdist — cp311-abi3, 7 platforms + sdist.
# THIS JOB IS NOT REF-GUARDED: it runs on both workflow_dispatch (dry run)
# and tag push. Only the upload to PyPI (publish-python) is tag-guarded.
# (PF-039: every step behind a ref guard is code the dry run cannot execute;
# the build + verification gate must run in dispatch mode to be tested.)
#
# The wheel verification gate (readelf on Linux legs) guards against silent
# glibc contamination of musl targets — a toolchain fallback that produces an
# artifact with the right NAME and the wrong libc (PF-038). It mirrors the
# build-napi musl readelf gate: positive control + non-vacuity guard.
#
# build-python is in publish-crates's needs: so a Python build failure aborts
# the release before ANY registry write (crates.io is irreversible — PF-023).
# ---------------------------------------------------------------------------
build-python:
name: Build Python (${{ matrix.target || 'sdist' }})
needs: [version-gate]
if: ${{ !cancelled() && needs.version-gate.result == 'success' }}
strategy:
fail-fast: false
matrix:
include:
# --- manylinux (glibc) ---
- host: ubuntu-latest
target: x86_64-unknown-linux-gnu
manylinux: "2_17"
plat-tag: manylinux_2_17_x86_64
libc: glibc
- host: ubuntu-latest
target: aarch64-unknown-linux-gnu
manylinux: "2_17"
plat-tag: manylinux_2_17_aarch64
libc: glibc
# --- musllinux ---
- host: ubuntu-latest
target: x86_64-unknown-linux-musl
manylinux: musllinux_1_2
plat-tag: musllinux_1_2_x86_64
libc: musl
- host: ubuntu-latest
target: aarch64-unknown-linux-musl
manylinux: musllinux_1_2
plat-tag: musllinux_1_2_aarch64
libc: musl
# --- macOS ---
- host: macos-latest
target: aarch64-apple-darwin
plat-tag: macosx_.*_arm64
expect-arch: arm64
- host: macos-latest
target: x86_64-apple-darwin
plat-tag: macosx_.*_x86_64
expect-arch: x86_64
# --- Windows ---
- host: windows-latest
target: x86_64-pc-windows-msvc
plat-tag: win_amd64
# --- sdist (source distribution, once) ---
- host: ubuntu-latest
sdist: true
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref }}
# Rust toolchain needed for macOS/Windows wheel builds. Linux legs run
# inside maturin-action containers that provide their own Rust toolchain,
# so we skip the host install there — it is unreachable by the container
# build and its artifacts would poison the shared Swatinem/rust-cache key
# (host target/release/build/ artifacts built on Ubuntu 22.04 have a
# higher glibc requirement than the manylinux 2.17 container runtime).
- uses: dtolnay/rust-toolchain@stable
if: ${{ !matrix.sdist && runner.os != 'Linux' }}
with:
targets: ${{ matrix.target }}
# Per-leg cache key: target + manylinux flavour so a containerised Linux
# leg (glibc 2.17 manylinux / musl) never restores host-built build scripts
# or proc-macro .so files written by another leg; legs without manylinux
# render a trailing dash (harmless, validated warm in #347). Intentional;
# spec S20 in scripts/__test__/release-auth-probe.spec.mjs pins it.
- uses: Swatinem/rust-cache@v2
if: ${{ !matrix.sdist }}
with:
key: ${{ matrix.target }}-${{ matrix.manylinux }}
# --- build wheel ---
- name: Build wheel
if: ${{ !matrix.sdist }}
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
command: build
args: --release --locked --compatibility pypi -m crates/mds-python/Cargo.toml --out dist
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
maturin-version: "v1.13.3"
# --- build sdist ---
- name: Build sdist
if: ${{ matrix.sdist }}
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
command: sdist
args: -m crates/mds-python/Cargo.toml --out dist
maturin-version: "v1.13.3"
# --- wheel filename gate (all wheel legs) ---
# Asserts cp311-abi3 tag and the expected platform string are both present.
# A wrong Python tag or missing platform tag is a packaging defect that
# would be accepted by pip but install under the wrong interpreter.
- name: Assert wheel filename (cp311-abi3 + platform tag)
if: ${{ !matrix.sdist }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
set -- dist/*.whl
[ "$#" -eq 1 ] \
|| { echo "::error::expected exactly 1 .whl in dist/, found $#${1:+: $*}"; exit 1; }
WHEEL="$1"
FNAME=$(basename "$WHEEL")
echo "Wheel: $FNAME"
echo "$FNAME" | grep -q 'cp311-abi3' \
|| { echo "::error::wheel is not cp311-abi3: $FNAME"; exit 1; }
PLAT_TAG="${{ matrix.plat-tag }}"
echo "$FNAME" | grep -Eq "$PLAT_TAG" \
|| { echo "::error::wheel platform tag missing '$PLAT_TAG': $FNAME"; exit 1; }
echo "Wheel filename OK"
# --- wheel version gate (all wheel legs) ---
# Asserts the wheel version matches the workspace Cargo version. Maturin
# reads the version dynamically from Cargo.toml, so this mainly guards
# against a misconfigured pyproject.toml overriding dynamic versioning.
- name: Assert wheel version matches workspace version
if: ${{ !matrix.sdist }}
shell: bash
run: |
set -euo pipefail
# Read workspace version directly from root Cargo.toml (no cargo
# invocation needed; the version is a plain string in the file).
# awk 'NR==1{print;exit}' avoids SIGPIPE from head -1 under pipefail (F11).
WS_VER=$(grep -A20 '^\[workspace\.package\]' Cargo.toml \
| grep '^version' | awk 'NR==1{print;exit}' | sed 's/.*"\([^"]*\)".*/\1/')
# F12: Cargo pre-release versions (e.g. 0.5.0-rc.1) normalize differently
# under PEP 440 (0.5.0rc1 in the wheel name). Fail loudly rather than
# silently comparing mismatched formats.
if echo "$WS_VER" | grep -q '-'; then
echo "::error::pre-release workspace version '$WS_VER' is not supported by this gate — PEP 440 normalization differs from Cargo syntax (e.g. 0.5.0-rc.1 becomes 0.5.0rc1 in wheel names). Verify the wheel filename field manually."
exit 1
fi
shopt -s nullglob
set -- dist/*.whl
WHEEL="$1"
# Wheel filename: {name}-{version}-{python}-{abi}-{platform}.whl
WHEEL_VER=$(basename "$WHEEL" | cut -d'-' -f2)
echo "Workspace version: $WS_VER Wheel version: $WHEEL_VER"
[ "$WS_VER" = "$WHEEL_VER" ] \
|| { echo "::error::wheel version $WHEEL_VER != workspace version $WS_VER"; exit 1; }
echo "Wheel version OK"
# --- libc linkage gate (Linux wheel legs only — readelf available) ---
# Asserts the .so inside the wheel links the correct libc. A cross-compile
# toolchain with no musl entry silently falls back to the host glibc
# compiler, producing an artifact with the right NAME and the wrong libc
# (PF-038; mirrors the build-napi musl readelf gate exactly).
- name: Assert wheel links correct libc (PF-038)
if: ${{ runner.os == 'Linux' && !matrix.sdist }}
shell: bash
run: |
set -euo pipefail
LIBC="${{ matrix.libc }}"
GLIBC_RE='libc\.so\.6|ld-linux'
# Positive control: the glibc detector MUST fire on a planted NEEDED
# line. A silently broken detector cannot pass as "clean" (PF-038).
printf ' 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]\n' \
| grep -Eq "$GLIBC_RE" \
|| { echo "::error::glibc detector self-test failed to fire"; exit 1; }
shopt -s nullglob
set -- dist/*.whl
[ "$#" -eq 1 ] \
|| { echo "::error::expected exactly 1 .whl in dist/, found $#"; exit 1; }
TMP=$(mktemp -d)
unzip -q "$1" -d "$TMP"
# -print -quit: stop after the first match; avoids SIGPIPE from | head -1
# under set -euo pipefail (F11).
SO=$(find "$TMP" -name "*.so" -print -quit)
[ -n "$SO" ] \
|| { echo "::error::no .so found in wheel $1"; exit 1; }
echo "Checking: $SO"
readelf -d "$SO" > "$TMP/dyn.txt"
# Non-vacuity guard: NEEDED entries must exist. An empty parse reads
# as "clean" but proves nothing (PF-038).
grep -q 'NEEDED' "$TMP/dyn.txt" \
|| { echo "::error::no NEEDED entries in $SO — parse failed or artifact is static"; exit 1; }
if [ "$LIBC" = "musl" ]; then
# musllinux: must NOT link glibc
if grep -Eq "$GLIBC_RE" "$TMP/dyn.txt"; then
echo "::error::$SO is glibc-linked; expected musl (libc.so)"
cat "$TMP/dyn.txt"
exit 1
fi
# Positive assertion: musl cdylib links musl's libc by its soname libc.so
grep -q 'Shared library: \[libc\.so\]' "$TMP/dyn.txt" \
|| { echo "::error::$SO does not link musl libc.so"; cat "$TMP/dyn.txt"; exit 1; }
else
# manylinux (glibc): must link glibc
if ! grep -Eq "$GLIBC_RE" "$TMP/dyn.txt"; then
echo "::error::$SO is not glibc-linked; expected glibc for manylinux target"
cat "$TMP/dyn.txt"
exit 1
fi
fi
cat "$TMP/dyn.txt"
rm -rf "$TMP"
# --- sdist version gate (sdist leg only) ---
# Asserts the sdist version matches the workspace Cargo version. Maturin
# reads the version dynamically, but this guards against a misconfigured
# pyproject.toml override. The sdist is published to PyPI and is the
# fallback for every platform without a wheel (F9).
- name: Assert sdist version matches workspace version
if: ${{ matrix.sdist }}
shell: bash
run: |
set -euo pipefail
WS_VER=$(grep -A20 '^\[workspace\.package\]' Cargo.toml \
| grep '^version' | awk 'NR==1{print;exit}' | sed 's/.*"\([^"]*\)".*/\1/')
shopt -s nullglob
set -- dist/*.tar.gz
[ "$#" -eq 1 ] || { echo "::error::expected exactly 1 .tar.gz in dist/, found $#"; exit 1; }
# PEP 625: sdist filename is {name}-{version}.tar.gz (name normalized with underscores).
# rev | cut -d- -f1 | rev extracts the last hyphen-delimited field (the version),
# which works for names with or without hyphens.
SDIST_VER=$(basename "$1" .tar.gz | rev | cut -d- -f1 | rev)
echo "Workspace version: $WS_VER Sdist version: $SDIST_VER"
[ "$WS_VER" = "$SDIST_VER" ] \
|| { echo "::error::sdist version $SDIST_VER != workspace version $WS_VER"; exit 1; }
echo "Sdist version OK"
# --- macOS arch gate (macOS wheel legs only — lipo available) ---
# Darwin analogue of the Linux readelf gate (PF-038): asserts the .so inside
# the wheel carries the expected architecture slice.
- name: Assert macOS wheel arch (PF-038, Darwin analogue)
if: ${{ runner.os == 'macOS' && !matrix.sdist }}
shell: bash
run: |
set -euo pipefail
TMP=$(mktemp -d)
unzip -q dist/*.whl -d "$TMP"
SO=$(find "$TMP" -name '*.so' -print -quit)
ARCHS=$(lipo -archs "$SO")
echo "$ARCHS" | grep -qw "${{ matrix.expect-arch }}" \
|| { echo "::error::$SO is [$ARCHS], expected ${{ matrix.expect-arch }}"; exit 1; }
echo "macOS arch OK: $SO is [$ARCHS]"
rm -rf "$TMP"
- uses: actions/upload-artifact@v7
with:
name: python-${{ matrix.target || 'sdist' }}
path: dist/
if-no-files-found: error
# ===========================================================================
# Rehearsal jobs — run on pull_request, workflow_dispatch, AND tag push.
# Intentionally unguarded (no if:) so PRs exercise the publish path before
# any irreversible crates.io publish (PF-039: tag-guarded steps unexercised).
# ===========================================================================
# ---------------------------------------------------------------------------
# Rehearse publish-python WITHOUT uploading anything.
#
# publish-python is the one job no dry run can execute (PF-039): it is
# tag-guarded, and its payload — an upload to pypi.org — is irreversible and
# therefore unrehearsable by definition. What CAN be rehearsed is everything
# that has actually broken it: the SHAPE of the pypa/gh-action-pypi-publish
# pin, the GHCR image that pin resolves to, and the metadata of the
# distributions twine is asked to upload. The v0.4.1 break was a docker pull
# that failed before PyPI was contacted at all (PF-040).
#
# This job MUST NOT invoke pypa/gh-action-pypi-publish. That action has no
# dry-run / no-upload mode: an unrecognised `dry-run:` input is warned about
# and ignored, and the action then uploads for real. Run 34060146952 — a
# pull_request run of this workflow — did exactly that, and was saved only by
# the workspace version already existing on PyPI. So the rehearsal REPRODUCES
# what the action does (pull its image, run twine out of it) instead of
# calling it. Spec S14 pins that; S15 additionally withholds id-token so this
# job has no credential to upload with even if a step is re-introduced.
#
# Every gate below carries a POSITIVE CONTROL (PF-013): a known-bad input the
# gate must reject. A gate never observed rejecting anything is not evidence.
# ---------------------------------------------------------------------------
rehearse-publish-python:
name: Rehearse PyPI publish (no upload)
needs: [build-python]
if: ${{ !cancelled() && needs.build-python.result == 'success' }}
runs-on: ubuntu-latest
permissions:
# Deliberately NO OIDC write permission here. A job that cannot mint a
# PyPI trusted-publishing credential cannot upload, even if an upload step
# is re-introduced by mistake — defence in depth behind spec S14/S15.
contents: read
env:
# Single source of truth for the pin under test. Must equal the ref in the
# two publish steps that invoke pypa/gh-action-pypi-publish (publish-python and
# publish-testpypi) — spec S16 pins that equality, because a rehearsal that
# validates a different ref than the release pulls proves nothing (PF-040).
PIN_REF: v1.14.2
PUBLISH_IMAGE: ghcr.io/pypa/gh-action-pypi-publish
# A ref that must never exist — the known-bad input every positive control
# below is exercised against.
BOGUS_REF: v0.0.0-mds-does-not-exist
steps:
- name: Download Python artifacts
uses: actions/download-artifact@v8
with:
pattern: python-*
path: python-dist/
merge-multiple: true
# Mirrors the F4 completeness gate in publish-python: download-artifact
# exits 0 on zero matches, so an empty python-dist/ would make every check
# below vacuously green (PF-013).
- name: Assert all 8 Python distributions are present
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
ls -la python-dist/
whl=(python-dist/*.whl); sd=(python-dist/*.tar.gz)
[ "${#whl[@]}" -eq 7 ] || { echo "::error::expected 7 wheels, found ${#whl[@]}"; exit 1; }
[ "${#sd[@]}" -eq 1 ] || { echo "::error::expected 1 sdist, found ${#sd[@]}"; exit 1; }
echo "Python distributions complete: ${#whl[@]} wheels + ${#sd[@]} sdist"
- name: "Gate 1/4: publish pin is a release tag, not a SHA (PF-040)"
shell: bash
run: |
set -euo pipefail
# A commit SHA — or an annotated-tag-object SHA — is a well-formed,
# GPG-verifiable git ref that GHCR has no image for. v0.4.1 shipped
# exactly that and publish-python died on "manifest unknown" AFTER
# crates.io and 15 npm packages had published irreversibly.
is_release_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
# Positive control first (PF-013): BOTH known-bad pins must be REJECTED.
# a892a5a6 is the annotated tag OBJECT of v1.14.2 (the v0.4.1 break).
# dc37677b is the COMMIT sha of v1.14.2 — a real, image-backed commit
# sha — the shape check must reject it too (the gate enforces policy,
# not existence: a commit sha may have an image but is ambiguous by eye).
for BAD_PIN in a892a5a61159132606e93a2fa6f4358831b04d26 dc37677b2e1c63e2034f94d8a5b11f265b73ba33; do