-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Expand file tree
/
Copy pathtest_release_workflows.py
More file actions
1469 lines (1235 loc) · 67.3 KB
/
Copy pathtest_release_workflows.py
File metadata and controls
1469 lines (1235 loc) · 67.3 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
"""Workflow regression tests for release publishing behavior."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from packaging.requirements import Requirement
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
import tomli as tomllib # type: ignore[no-redef]
ROOT = Path(__file__).resolve().parent.parent
def test_every_published_docker_variant_includes_bedrock_auth_dependencies() -> None:
"""Every image that advertises ``--backend bedrock`` must ship botocore.
Temporary AWS credentials take LiteLLM's botocore-backed authentication
path. The default images previously installed only ``proxy``/``code``, so
the documented Docker Bedrock command failed at runtime with
``No module named 'botocore'`` (#1551). Keep the standalone Dockerfile and
every bake target on the existing ``bedrock`` package extra.
"""
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "ARG HEADROOM_EXTRAS=proxy,code,bedrock" in dockerfile
bake = (ROOT / "docker-bake.hcl").read_text(encoding="utf-8")
extras_lines = [
line.strip() for line in bake.splitlines() if line.strip().startswith("HEADROOM_EXTRAS =")
]
assert len(extras_lines) == 9
assert all("bedrock" in line for line in extras_lines), extras_lines
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
bedrock_names = {
Requirement(requirement).name
for requirement in project["project"]["optional-dependencies"]["bedrock"]
}
assert {"boto3", "botocore"} <= bedrock_names
def test_public_docker_instructions_use_the_current_organization_package() -> None:
"""Do not send users back to the personal GHCR package frozen at 0.27.0."""
public_docs = (
"README.md",
"llms.txt",
"docker-compose.yml",
"TESTING-copilot-subscription.md",
"wiki/cli.md",
"wiki/docker-install.md",
)
deprecated = "ghcr.io/chopratejas/headroom"
current = "ghcr.io/headroomlabs-ai/headroom"
for relative_path in public_docs:
content = (ROOT / relative_path).read_text(encoding="utf-8")
assert deprecated not in content, relative_path
assert current in content, relative_path
def test_docker_workflow_normalizes_repository_name_for_signing() -> None:
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
assert "id: image-name" in content
assert "tr '[:upper:]' '[:lower:]'" in content
assert "steps.image-name.outputs.image_name" in content
def test_docker_latest_promotion_is_owned_by_root_manifest_cell() -> None:
workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "docker.yml").read_text())
jobs = workflow["jobs"]
build = jobs["docker-build"]
manifest = jobs["docker-manifest"]
variants = manifest["strategy"]["matrix"]["variant"]
build_variants = build["strategy"]["matrix"]["variant"]
architectures = build["strategy"]["matrix"]["arch"]
root = next(entry for entry in variants if entry["name"] == "")
nonroot = next(entry for entry in variants if entry["name"] == "nonroot")
promotion = next(
step for step in manifest["steps"] if step["name"] == "Re-tag root image as :latest"
)
command = promotion["run"]
assert len(variants) == 8
assert [entry["name"] for entry in build_variants] == [entry["name"] for entry in variants]
assert len(architectures) == 2
assert {entry["platform"] for entry in architectures} == {"linux/amd64", "linux/arm64"}
assert root["name"] == ""
assert nonroot["name"] == "nonroot"
assert "matrix.variant.name == ''" in promotion["if"]
assert "steps.manifest.outputs.index_digest != ''" in promotion["if"]
assert "steps.version.outputs.version != ''" in promotion["if"]
assert (
promotion["if"]
== "steps.manifest.outputs.index_digest != '' && matrix.variant.name == '' && steps.version.outputs.version != ''"
)
assert '"${IMAGE}:latest"' in command
assert '"${IMAGE}:${VERSION}"' in command
assert "promote-latest" not in jobs
assert manifest["needs"] == "docker-build"
assert manifest["if"] == "${{ always() }}"
step_names = [step["name"] for step in manifest["steps"]]
assert step_names.index("Sign multi-arch index manifest with cosign") < step_names.index(
"Re-tag root image as :latest"
)
manifest_script = next(
step["run"] for step in manifest["steps"] if step["name"] == "Create multi-arch manifest"
)
assert 'digest_count="$(find "${DIGEST_DIR}" -maxdepth 1 -type f | wc -l)"' in manifest_script
assert '"${digest_count}" -ne 2' in manifest_script
assert manifest_script.index('"${digest_count}" -ne 2') < manifest_script.index(
"docker buildx imagetools create"
)
guard_start = manifest_script.index('"${digest_count}" -ne 2')
create_start = manifest_script.index("docker buildx imagetools create")
assert guard_start < manifest_script.index("exit 1", guard_start) < create_start
def test_docker_manifest_downloads_exactly_one_artifact_per_architecture() -> None:
"""Each manifest cell must download exactly its two architecture digests.
Keeping the variant before a trailing wildcard makes prefix-related names
overlap: ``digests-code-*`` also selects code-nonroot, code-slim, and
code-slim-nonroot. The 0.35.0 Docker release exposed this by downloading
eight markers into the code manifest job instead of two.
"""
workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "docker.yml").read_text())
jobs = workflow["jobs"]
build = jobs["docker-build"]
manifest = jobs["docker-manifest"]
upload = next(step for step in build["steps"] if step.get("name") == "Upload digest marker")
downloads = [
step
for step in manifest["steps"]
if step.get("name")
in {
"Download amd64 digest for this variant",
"Download arm64 digest for this variant",
}
]
assert upload["with"]["name"] == (
"digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}"
)
assert [step["with"]["name"] for step in downloads] == [
"digests-${{ matrix.variant.name || 'root' }}-amd64",
"digests-${{ matrix.variant.name || 'root' }}-arm64",
]
assert all("pattern" not in step["with"] for step in downloads)
assert all(step["with"]["path"] == "${{ runner.temp }}/digests" for step in downloads)
def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "Publish ${{ env.NPM_SDK_PACKAGE }} to GitHub Package Registry" in content
assert "Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to GitHub Package Registry" in content
assert "pkg.name = `@${process.env.GITHUB_PACKAGES_SCOPE}/${pkg.name}`;" in content
assert (
'unscoped_sdk_tarball="$(npm pack --pack-destination "$assets_dir" | tail -n 1)"' in content
)
assert "SDK_TARBALL: ${{ steps.gpr-sdk-publish.outputs.unscoped_sdk_tarball }}" in content
def test_release_workflow_publishes_python_distributions_to_github_release() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "Publish ${{ env.PYPI_PACKAGE }} Python distributions to GitHub Release" in content
assert (
'gh release upload "$TAG" release-assets/*.whl release-assets/*.tar.gz --clobber' in content
)
assert "Publish Node package tarballs to GitHub Release" in content
assert 'gh release upload "$TAG" release-assets/*.tgz --clobber' in content
def test_create_release_requires_successful_build_and_pypi_publish() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# Single-wheel maturin refactor (PR #360) added `build-wheels` (the
# cross-platform matrix that produces the linux/macos/aarch64 wheels)
# and `collect-dist` (aggregator that merges wheel artifacts + npm
# release-assets) between `build` and the publish jobs. create-release
# must wait for all of them.
# PR #387 (X1) added `smoke-import-wheels` — the runtime gate that
# actually loads the wheel on a customer-representative environment
# before publish. create-release must wait for it AND require its
# success in the `if:` block (otherwise `always()` would let the
# release proceed even when the smoke gate failed).
assert (
"needs: [detect-version, build, build-wheels, collect-dist, smoke-import-wheels, publish-pypi, publish-npm, publish-github-packages, publish-docker]"
in content
)
assert "always()" in content
assert "needs.build.result == 'success'" in content
assert "needs.build-wheels.result == 'success'" in content
assert "needs.collect-dist.result == 'success'" in content
assert "needs.smoke-import-wheels.result == 'success'" in content
assert "(vars.PYPI_SKIP == 'true' || needs.publish-pypi.result == 'success')" in content
def test_macos_native_wrapper_dependency_install_retries_pypi_downloads() -> None:
content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
assert "python -m pip install --retries 10 --timeout 60 pytest" in content
def test_ci_commitlint_runs_only_for_pull_requests() -> None:
content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
assert "github.event_name == 'pull_request'" in content
def test_no_openssl_sys_in_wheel_build_tree() -> None:
"""STRUCTURAL INVARIANT: openssl-sys must NOT appear in the wheel
build's resolved dependency graph.
This is the load-bearing assertion for the entire build pipeline.
If openssl-sys is in the wheel-build resolution graph, every
Linux/macOS surface that builds from source needs system OpenSSL
+ perl modules + pkg-config — and we've spent five hot-fixes
chasing whichever combination of perl modules / OpenSSL versions
/ pkg-config paths was missing in each manylinux/Dockerfile/
devcontainer surface. The cleanest fix is to NOT depend on
OpenSSL at all.
fastembed exposes `hf-hub-rustls-tls` and
`ort-download-binaries-rustls-tls` features that replace its
default `native-tls` path. With `default-features = false` plus
those rustls features enabled in headroom-core, our entire build
tree uses rustls and no crate pulls openssl-sys.
This test runs `cargo tree` (so it actually exercises the
resolved feature graph, not just declared Cargo.toml features).
A future refactor that adds a transitive native-tls user will
fail here, surfaced at PR time rather than 5 minutes into a CI
wheel-build error.
"""
import subprocess
for crate in ("headroom-py", "headroom-proxy", "headroom-core"):
try:
result = subprocess.run(
[
"cargo",
"tree",
"--target",
"x86_64-unknown-linux-gnu",
"-p",
crate,
"-i",
"openssl-sys",
],
cwd=str(ROOT),
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
pytest.skip("cargo is unavailable in this environment")
# `cargo tree -i <pkg>` exits 101 with "did not match any
# packages" when the package is NOT in the tree — the GREEN
# case. Exit 0 with a tree of consumers means it IS pulled.
not_in_tree = result.returncode != 0 and "did not match any packages" in result.stderr
if (
result.returncode != 0
and "package ID specification `openssl-sys` did not match"
not in (result.stderr + result.stdout)
):
pytest.skip(
"cargo dependency tree for the Linux wheel target is unavailable in this environment"
)
assert not_in_tree, (
f"openssl-sys is back in {crate}'s build tree:\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}\n"
"Find the new native-tls user (likely a default-features=true "
"on a transitive crate) and disable it. Switching every "
"transitive HTTP+TLS consumer to rustls is the load-bearing "
"invariant that keeps wheel builds working without system "
"OpenSSL or perl modules."
)
def test_no_native_tls_in_wheel_build_tree() -> None:
"""The dual of the openssl-sys gate: native-tls is the proximate
cause of openssl-sys being pulled. Catch it earlier with a more
specific error message so future debugging starts at the right
place.
"""
import subprocess
for crate in ("headroom-py", "headroom-proxy", "headroom-core"):
result = subprocess.run(
[
"cargo",
"tree",
"--target",
"x86_64-unknown-linux-gnu",
"-p",
crate,
"-i",
"native-tls",
],
cwd=str(ROOT),
capture_output=True,
text=True,
check=False,
)
not_in_tree = result.returncode != 0 and "did not match any packages" in result.stderr
assert not_in_tree, (
f"native-tls is back in {crate}'s build tree — likely some "
f"crate's `default-features = true` re-enabled native-tls "
f"transitively:\n{result.stdout}"
)
def test_fastembed_uses_rustls_features() -> None:
"""The mechanism that keeps openssl-sys out of the build is
fastembed's explicit rustls feature selection in headroom-core.
fastembed's default features include `hf-hub-native-tls` (pulls
openssl-sys). Disabling defaults and enabling the rustls
equivalent removes the OpenSSL surface entirely.
"""
cargo = (ROOT / "crates" / "headroom-core" / "Cargo.toml").read_text(encoding="utf-8")
assert "default-features = false" in cargo
assert '"hf-hub-rustls-tls"' in cargo
# `image-models` is in default; we re-enable it explicitly so we
# don't lose the image-embedding capability when defaults are off.
assert '"image-models"' in cargo
def test_fastembed_uses_dynamic_ort_everywhere() -> None:
"""No build may statically link Pyke's prebuilt ORT binaries.
`ort-download-binaries-*` emits platform SDK link libs (DirectML on
Windows; no prebuilts for `x86_64-apple-darwin`) and its Linux/macOS
binaries require AVX2 at load time, SIGILLing `import headroom._core`
on pre-AVX2 x86-64 CPUs (#1278). Every platform loads ORT dynamically
(`ort-load-dynamic`), resolved at runtime from the pip `onnxruntime`
package by `headroom/_ort.py` / the crate's loader guard.
"""
cargo = (ROOT / "crates" / "headroom-core" / "Cargo.toml").read_text(encoding="utf-8")
dependency_lines = "\n".join(
line for line in cargo.splitlines() if not line.lstrip().startswith("#")
)
assert '"ort-load-dynamic"' in dependency_lines
assert "ort-download-binaries" not in dependency_lines
def test_dockerfiles_no_longer_install_openssl_devel() -> None:
"""Once openssl-sys is out of the build tree, every Dockerfile
that used to install `openssl-devel` / `libssl-dev` for the Rust
build can drop those packages. This test enforces the cleanup so
a future refactor doesn't carry the old packages forward "just
in case".
The check looks only at non-comment lines so explanatory comments
that mention the historical packages don't false-positive.
"""
targets = [
ROOT / "e2e" / "wrap" / "Dockerfile",
ROOT / "e2e" / "init" / "Dockerfile",
ROOT / "Dockerfile",
ROOT / ".devcontainer" / "Dockerfile",
]
forbidden = ["openssl-devel", "libssl-dev"]
for target in targets:
content = target.read_text(encoding="utf-8")
non_comment = "\n".join(
line for line in content.splitlines() if not line.lstrip().startswith("#")
)
for pkg in forbidden:
assert pkg not in non_comment, (
f"{target.relative_to(ROOT)} still installs {pkg!r} on a "
f"non-comment line. The rustls-everywhere refactor removed "
f"openssl-sys from the build tree; this package is no "
f"longer needed."
)
def test_release_yml_does_not_install_openssl_or_perl_for_wheels() -> None:
"""With openssl-sys out of the build tree (verified by
test_no_openssl_sys_in_wheel_build_tree), the previous
before-script-linux that installed perl-IPC-Cmd / perl /
perl-utils for the openssl-src vendored Configure script is
obsolete. Removing it speeds the wheel build and keeps the
Linux entry honest — every package install we keep here
represents a hidden assumption about the manylinux container.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
non_comment = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
# No legacy install commands or env vars must appear on a non-comment
# line. Each forbidden token represents an assumption about system
# OpenSSL that the rustls refactor removed.
forbidden = [
"openssl-devel",
"libssl-dev",
"perl-IPC-Cmd",
"libipc-cmd-perl",
"OPENSSL_DIR",
]
for token in forbidden:
assert token not in non_comment, (
f"release.yml build-wheels job still references {token!r} on "
f"a non-comment line. The rustls-everywhere refactor removed "
f"openssl-sys from the build tree; this command/env is now "
f"obsolete."
)
def test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort() -> None:
"""Intel macOS wheels use `ort-load-dynamic` because `ort-sys 2.0.0-rc.12`
has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`.
We assert against the actual matrix entry shape (`target: <triple>`
on a non-comment line) so explanatory comments mentioning other
triples don't false-positive.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
matrix_targets: list[str] = []
for raw in body.splitlines():
stripped = raw.lstrip()
# Skip YAML comments — only look at real matrix-entry lines.
if stripped.startswith("#"):
continue
if stripped.startswith("target:"):
# `target: x86_64-apple-darwin` → `x86_64-apple-darwin`
matrix_targets.append(stripped.split(":", 1)[1].strip())
assert "aarch64-apple-darwin" in matrix_targets, "Apple Silicon must stay in the matrix"
assert "x86_64-unknown-linux-gnu" in matrix_targets
assert "aarch64-unknown-linux-gnu" in matrix_targets
assert "x86_64-apple-darwin" in matrix_targets, (
f"x86_64-apple-darwin must be a wheel-matrix target; got {matrix_targets}"
)
matrix_os: list[str] = []
for raw in body.splitlines():
stripped = raw.lstrip()
if stripped.startswith("#"):
continue
if stripped.startswith("os:"):
matrix_os.append(stripped.split(":", 1)[1].strip())
elif stripped.startswith("- os:"):
matrix_os.append(stripped.split(":", 1)[1].strip())
assert "macos-15-intel" in matrix_os
def test_smoke_import_macos_selects_wheel_arch_from_target() -> None:
"""The macOS smoke-import step must pick the wheel tag from the matrix
target (arm64 for Apple Silicon, x86_64 for Intel) instead of
hardcoding `_arm64` for every macOS row."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
step_start = content.index("- name: Smoke-import wheel on macOS host")
step_end = content.index("- name: Smoke-import wheel on Windows host", step_start)
macos_block = content[step_start:step_end]
assert "WHEEL_TARGET: ${{ matrix.wheel_target }}" in macos_block
assert "aarch64-apple-darwin) mac_arch=arm64" in macos_block
assert "x86_64-apple-darwin) mac_arch=x86_64" in macos_block
assert "macosx_*_${mac_arch}.whl" in macos_block
assert "headroom_ai-*-${py_tag}-${py_tag}-macosx_*_arm64.whl" not in macos_block
assert "headroom_ai-*-abi3-macosx_*_arm64.whl" not in macos_block
def test_aarch64_wheel_uses_native_arm64_runner() -> None:
"""STRUCTURAL INVARIANT: the aarch64 wheel matrix row must run on a
native arm64 runner (`ubuntu-24.04-arm`), NOT a QEMU-emulated x64
runner (`ubuntu-latest`).
Pre-#377 we built the aarch64 wheel on `ubuntu-latest` (x86_64) inside
`manylinux_2_28_aarch64` via QEMU emulation, taking ~50–60 min. Native
arm64 GitHub-hosted runners (GA Jan 2025, free for public repos) drop
QEMU and complete the same build in ~10 min.
A future "let me unify all wheel rows on `ubuntu-latest`" refactor
would silently re-introduce QEMU and slow CI back down — this test
pins the runner.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
# Walk the matrix.include rows. Each row is a contiguous block of
# `key: value` lines starting with `os:` (the first key in our
# convention). Pair `os:` with the immediately-following `target:`
# so we can assert per-row.
rows: list[dict[str, str]] = []
current: dict[str, str] = {}
for raw in body.splitlines():
stripped = raw.lstrip()
if stripped.startswith("#"):
continue
if stripped.startswith("- os:"):
if current:
rows.append(current)
current = {"os": stripped.split(":", 1)[1].strip()}
elif stripped.startswith("target:") and current:
current["target"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("manylinux:") and current:
current["manylinux"] = stripped.split(":", 1)[1].strip()
if current:
rows.append(current)
aarch64_linux = [r for r in rows if r.get("target") == "aarch64-unknown-linux-gnu"]
assert len(aarch64_linux) == 1, f"expected exactly one aarch64-linux row; got {aarch64_linux}"
assert aarch64_linux[0]["os"] == "ubuntu-24.04-arm", (
f"aarch64-unknown-linux-gnu must run on native arm64 runner "
f"`ubuntu-24.04-arm`, not {aarch64_linux[0]['os']!r}. Reverting "
f"to `ubuntu-latest` re-introduces QEMU emulation and ~6× slower "
f"wheel builds."
)
# The amd64 Linux row should also be pinned to ubuntu-24.04 (not
# `ubuntu-latest`, which is a moving target). Pinning keeps the
# wheel-build environment reproducible across runner image rolls.
amd64_linux = [r for r in rows if r.get("target") == "x86_64-unknown-linux-gnu"]
assert len(amd64_linux) == 1
assert amd64_linux[0]["os"] == "ubuntu-24.04", (
f"x86_64-unknown-linux-gnu should pin `ubuntu-24.04`, not "
f"{amd64_linux[0]['os']!r} — `ubuntu-latest` is a moving alias "
f"and reproducibility benefits from explicit pinning."
)
def test_docker_workflow_builds_on_native_arch_runners() -> None:
"""STRUCTURAL INVARIANT: the docker variant build must fan out per
arch onto native runners — `linux/amd64` on `ubuntu-24.04`,
`linux/arm64` on `ubuntu-24.04-arm`. No QEMU.
Pre-#377 each variant ran `docker bake` with
`platforms = ["linux/amd64","linux/arm64"]` on a single x64 runner
using QEMU for arm64 emulation — ~1h per variant. Splitting into
16 native single-arch builds (8 variants × 2 arches) + a manifest
merge job per variant cuts wall-clock to ~10 min and removes the
QEMU surface that contributed to transient build failures.
"""
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
# The fan-out job must exist with both runners in its arch matrix.
assert "docker-build:" in content, "docker-build fan-out job missing"
assert "runs_on: ubuntu-24.04, platform: linux/amd64" in content, (
"amd64 arch matrix entry must bind ubuntu-24.04 (native x86_64)"
)
assert "runs_on: ubuntu-24.04-arm, platform: linux/arm64" in content, (
"arm64 arch matrix entry must bind ubuntu-24.04-arm (native aarch64)"
)
# Per-arch builds must push by digest only — tags belong on the
# multi-arch manifest, applied later by docker-manifest.
assert "push-by-digest=true,name-canonical=true,push=true" in content, (
"per-arch builds must push by digest only; tags applied at manifest merge step"
)
# The QEMU action must NOT be invoked anywhere — its presence would
# mean someone re-introduced an emulated build path.
non_comment = "\n".join(
line for line in content.splitlines() if not line.lstrip().startswith("#")
)
assert "docker/setup-qemu-action" not in non_comment, (
"docker.yml must not invoke `docker/setup-qemu-action` — native "
"arm64 runners replaced QEMU. A new reference here means someone "
"re-emulated arm64 on an x64 runner."
)
# Manifest merge job must exist and depend on docker-build.
assert "docker-manifest:" in content
assert "needs: docker-build" in content
assert "docker buildx imagetools create" in content
def test_docker_per_arch_build_specifies_image_name_in_output() -> None:
"""STRUCTURAL INVARIANT: the per-arch bake's `*.output` spec must
include `name=<registry>/<image>` — without it, buildx fails with
the misleading `ERROR: tag is needed when pushing to registry`.
Background: pre-#377 each docker variant ran with bake-file-tags
(multi-arch tagged push), which gave bake the registry/image name
via the tag strings. PR #376 split into per-arch fan-out and
correctly removed bake-file-tags from the per-arch step (tags
belong on the multi-arch manifest, not on per-arch images). But
that left bake without ANY reference for the push target — no
tags AND no explicit `name=` in the output spec.
The first release after #376 merged failed every docker-build job
with "ERROR: tag is needed when pushing to registry". The fix is
to explicitly pass `name=<registry>/<image>` in the output spec
so bake knows the push target without needing tags.
A future refactor that removes the explicit name (e.g., "we
already have labels, surely buildx can figure it out") will
silently re-break this. This test pins it.
"""
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
# Find the per-arch build's *.output set line. Must contain
# `name=` with the registry+image-name expression.
output_line_present = (
"*.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true"
in content
)
assert output_line_present, (
"per-arch bake `*.output` must include `name=<registry>/<image>`. "
"Without it, buildx fails the push with 'tag is needed when pushing "
"to registry' because no tags AND no explicit name = no push target. "
"This is a regression of the docker-build break right after PR #376."
)
def test_sdist_build_conditional_keyed_on_target_not_os() -> None:
"""STRUCTURAL INVARIANT: the sdist build's `if` conditional must
key on `matrix.target`, not `matrix.os`.
Background: PR #376 changed the wheel matrix from `os: ubuntu-latest`
to `os: ubuntu-24.04` (explicit pinning, no semantic change in
practice). It silently broke the sdist build, whose `if` was
`matrix.os == 'ubuntu-latest' && matrix.target == 'x86_64-unknown-linux-gnu'`
— the literal `'ubuntu-latest'` no longer matched. Sdist never
built, `release-assets/*.tar.gz` was empty, and the create-release
job failed `gh release upload release-assets/*.tar.gz` with
"no matches found".
The fix is to key the conditional on `matrix.target` only — sdist
is platform-independent, so any single matrix row is a fine host.
`target` is more semantically meaningful than `os` here AND is
decoupled from any future host-runner rename.
This test pins the `target`-only conditional so a future "let's
add `os` back to the conditional for clarity" refactor will fail
at PR time, not 8 minutes into a release.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# Locate the "Build sdist" step.
sdist_marker = "name: Build sdist"
assert sdist_marker in content, "sdist build step missing from release.yml"
# Walk forward to the next `if:` line — that's the conditional.
sdist_idx = content.index(sdist_marker)
if_idx = content.index("if:", sdist_idx)
if_line_end = content.index("\n", if_idx)
if_line = content[if_idx:if_line_end]
# Must reference `matrix.target`. Must NOT reference `matrix.os`.
assert "matrix.target == 'x86_64-unknown-linux-gnu'" in if_line, (
f"sdist build conditional must check `matrix.target`; got: {if_line!r}"
)
assert "matrix.os" not in if_line, (
f"sdist build conditional must NOT depend on `matrix.os` — that's "
f"how PR #376 silently disabled the sdist build. Got: {if_line!r}"
)
def test_release_workflow_verifies_versions_before_build_outputs() -> None:
"""Release sync must be followed by an explicit cross-package version gate."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "scripts/verify-versions.py" in content
assert "scripts/version-sync.py" in content
assert content.count("python scripts/verify-versions.py") >= 2
first_sync = content.index("python scripts/version-sync.py --version")
first_verify = content.index("python scripts/verify-versions.py", first_sync)
changelog = content.index("name: Run changelog generation", first_verify)
assert first_sync < first_verify < changelog
second_sync = content.index("python scripts/version-sync.py --version", first_verify)
second_verify = content.index("python scripts/verify-versions.py", second_sync)
build_wheels = content.index("name: Build wheels", second_verify)
assert second_sync < second_verify < build_wheels
def test_release_workflow_uses_local_npm_asset_builder() -> None:
"""npm tarball metadata must be built and verified by the reusable local gate."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
verifier = (ROOT / "scripts" / "verify_npm_release_assets.mjs").read_text(encoding="utf-8")
assert (
'node scripts/build_npm_release_assets.mjs "${{ needs.detect-version.outputs.npm_version }}" release-assets'
in content
)
build_start = content.index("name: Build npm release packages")
upload_start = content.index("name: Upload release assets artifact", build_start)
build_block = content[build_start:upload_start]
assert "npm pack" not in build_block, (
"release.yml must not reimplement npm packing inline; the script "
"regenerates OpenClaw dist metadata and runs install/import smoke checks."
)
assert "scripts/build_npm_release_assets.mjs" in content
assert "scripts/verify_npm_release_assets.mjs" in content
assert "scripts/verify_npm_release_assets.mjs" in builder
assert "registerHeadroomPlugin" in verifier
def test_npm_release_builder_regenerates_openclaw_dist_metadata_after_rewrite() -> None:
"""OpenClaw's packed dist/package.json must see the release dependency."""
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
rewrite = builder.index("rewriteOpenClawReleaseDependency();")
prepare_dist = builder.index('runNode(["prepare-dist.mjs"], openClawDir);', rewrite)
pack = builder.index(
'runNpm(["pack", "--pack-destination", assetsDir], openClawDir);', prepare_dist
)
verify = builder.index(
'runNode(["scripts/verify_npm_release_assets.mjs", assetsDir, version], rootDir)', pack
)
assert rewrite < prepare_dist < pack < verify
def test_npm_release_builder_installs_openclaw_against_local_sdk_tarball() -> None:
"""The OpenClaw build must not require the release SDK to exist on npm."""
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
local_dependency = builder.index("rewriteOpenClawLocalDependency(sdkTarballPath);")
install = builder.index(
'["install", "--package-lock=false", "--no-audit", "--no-fund", "--ignore-scripts"]',
local_dependency,
)
build = builder.index('runNpm(["run", "build"], openClawDir);', install)
release_dependency = builder.index("rewriteOpenClawReleaseDependency();", build)
assert local_dependency < install < build < release_dependency
assert 'runNpm(["ci"], openClawDir)' not in builder
def test_openclaw_source_dependency_matches_lockfile_registry_range() -> None:
"""The source checkout must remain npm-ci installable before a release exists."""
import json
package_json = json.loads((ROOT / "plugins" / "openclaw" / "package.json").read_text())
package_lock = json.loads((ROOT / "plugins" / "openclaw" / "package-lock.json").read_text())
source_range = package_json["dependencies"]["headroom-ai"]
lock_range = package_lock["packages"][""]["dependencies"]["headroom-ai"]
assert source_range == lock_range == "^0.22.3"
def test_opencode_source_dependency_matches_lockfile_registry_range() -> None:
"""The source checkout must remain npm-ci installable before a release exists."""
import json
package_json = json.loads((ROOT / "plugins" / "opencode" / "package.json").read_text())
package_lock = json.loads((ROOT / "plugins" / "opencode" / "package-lock.json").read_text())
source_range = package_json["dependencies"]["headroom-ai"]
lock_range = package_lock["packages"][""]["dependencies"]["headroom-ai"]
assert source_range == lock_range == "^0.22.3"
def test_python_release_smoke_imports_installed_wheel_outside_source_tree() -> None:
"""The wheel smoke must not import the checkout package by accident."""
script = (ROOT / "scripts" / "build_python_release_smoke.py").read_text(encoding="utf-8")
assert "cwd: Path = ROOT" in script
assert 'import_cwd = Path(tmp) / "import-cwd"' in script
assert 'run([smoke_python, "-c", smoke_code], cwd=import_cwd)' in script
def test_publish_npm_regenerates_openclaw_dist_metadata_after_version_and_dependency() -> None:
"""The direct npm publish path must not ship stale OpenClaw dist metadata."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
start = content.index("name: Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to npmjs.org")
end = content.index("continue-on-error: true", start)
block = content[start:end]
version = block.index('npm version "$version"')
dependency = block.index('pkg.dependencies["headroom-ai"]')
prepare_dist = block.index("node prepare-dist.mjs")
publish = block.index("npm publish --access public")
assert version < dependency < prepare_dist < publish
def test_publish_npm_rewrites_opencode_dependency_after_version_and_before_publish() -> None:
"""The direct npm publish path must version and retarget the Opencode dependency."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
start = content.index("name: Publish ${{ env.NPM_OPENCODE_PACKAGE }} to npmjs.org")
end = content.index("continue-on-error: true", start)
block = content[start:end]
version = block.index('npm version "$version"')
dependency = block.index('pkg.dependencies["headroom-ai"]')
publish = block.index("npm publish --access public")
assert version < dependency < publish
def test_sdist_license_is_packaged_and_verified_before_upload() -> None:
"""STRUCTURAL INVARIANT: the sdist tarball must physically contain
every license file PEP 639 declares in PKG-INFO, and the release
workflow must verify that match before upload.
PyPI rejects sdists whose `License-File:` metadata entries
reference files missing from the tarball with `400 License-File X
does not exist in distribution file ...`. Maturin's PEP 639
auto-discovery emits both `LICENSE` and `NOTICE` into PKG-INFO
because both files exist at the project root and match the default
glob — but maturin sdists don't get the package-directory
treatment wheels do, so each file must be explicitly listed in
`[tool.maturin].include` with `format = "sdist"`. Issue trail:
sdist publish broke at v0.20.16 (the hatch -> maturin migration
in 2a91cbb dropped NOTICE from the include list), masked for ~22
releases by an earlier twine `400 File already exists` failure on
duplicate wheels, surfaced once PR #412 added skip-existing.
"""
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
release_yml = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert '{ path = "LICENSE", format = "sdist" }' in pyproject, (
"pyproject.toml [tool.maturin].include must list LICENSE for sdist format"
)
assert '{ path = "NOTICE", format = "sdist" }' in pyproject, (
"pyproject.toml [tool.maturin].include must list NOTICE for sdist format. "
"Maturin's PEP 639 auto-discovery emits `License-File: NOTICE` into "
"PKG-INFO because NOTICE exists at the project root, so the file MUST "
"ship in the tarball or PyPI rejects the sdist with a 400."
)
assert "name: Verify sdist license-file metadata matches tarball contents" in release_yml, (
"release.yml must run the License-File / tarball-contents cross-check before publish"
)
assert 'if line.startswith("License-File:")' in release_yml, (
"release.yml verifier must parse PKG-INFO License-File entries — "
"not just a hardcoded LICENSE check — so any future PEP 639-discoverable "
"file (COPYING, AUTHORS, ...) is also gated."
)
assert "declares License-File entries that are missing from the tarball" in release_yml, (
"release.yml verifier must fail loudly when declared license files "
"are missing — silent passes would let the same regression resurface."
)
def test_pypi_publish_failure_blocks_github_release() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
pypi_job_start = content.index("publish-pypi:")
npm_job_start = content.index("publish-npm:", pypi_job_start)
pypi_job = content[pypi_job_start:npm_job_start]
assert "uses: pypa/gh-action-pypi-publish@v1.14.2" in pypi_job
assert "continue-on-error: true" not in pypi_job
assert "(vars.PYPI_SKIP == 'true' || needs.publish-pypi.result == 'success')" in content
def test_glibc_compat_shim_present_in_headroom_py() -> None:
"""STRUCTURAL INVARIANT: the headroom-py crate ships a glibc-2.38
compatibility shim that defines weak `__isoc23_*` aliases.
Issue #355 (https://github.com/chopratejas/headroom/issues/355) —
the published wheel's `_core.so` references `__isoc23_strtoll`
(glibc 2.38+) because we statically link prebuilt ONNX Runtime
artifacts compiled with gcc 14. Users with libc < 2.38 (Ubuntu
22.04, most Conda envs, Debian 11/12) hit:
ImportError: undefined symbol: __isoc23_strtoll
The fix is `crates/headroom-py/glibc_compat.c` which provides
weak-alias definitions for the four `__isoc23_*` symbols,
delegating to the older `strtol*` family. `build.rs` compiles
the shim into `_core.so` on Linux/glibc only.
A future "let me drop this weird C file, surely it's dead code"
refactor would silently re-introduce the import failure for
every user on glibc < 2.38. This test pins all three load-bearing
pieces (the .c file, the build.rs trigger, the [build-dependencies]
cc dep).
"""
headroom_py_dir = ROOT / "crates" / "headroom-py"
shim = headroom_py_dir / "glibc_compat.c"
assert shim.exists(), (
"crates/headroom-py/glibc_compat.c is missing — without it, "
"`_core.so` fails to import on every glibc < 2.38 host. See "
"issue #355 for the full bug class. NEVER delete this file "
"without confirming via `scripts/audit_wheel_glibc_symbols.py` "
"that the wheel no longer references __isoc23_* symbols."
)
shim_content = shim.read_text(encoding="utf-8")
for sym in ("__isoc23_strtol", "__isoc23_strtoll", "__isoc23_strtoul", "__isoc23_strtoull"):
assert sym in shim_content, f"shim missing alias for {sym}"
build_rs = headroom_py_dir / "build.rs"
assert build_rs.exists(), "crates/headroom-py/build.rs is missing"
build_rs_content = build_rs.read_text(encoding="utf-8")
assert "glibc_compat.c" in build_rs_content, (
"build.rs must reference glibc_compat.c — otherwise Cargo "
"skips the shim and the wheel's `_core.so` ships without it."
)
cargo_toml = (headroom_py_dir / "Cargo.toml").read_text(encoding="utf-8")
assert 'build = "build.rs"' in cargo_toml, (
'headroom-py/Cargo.toml must declare `build = "build.rs"` — '
"Cargo only auto-detects build.rs when this is set; without "
"it, the shim never compiles."
)
assert "[build-dependencies]" in cargo_toml and 'cc = "1"' in cargo_toml, (
'headroom-py/Cargo.toml must declare `cc = "1"` in '
"[build-dependencies] for build.rs to compile the C shim."
)
def test_release_workflow_audits_wheel_glibc_symbols() -> None:
"""STRUCTURAL INVARIANT: the release workflow audits each Linux
wheel for symbol references that exceed its manylinux glibc floor.
Companion to `test_glibc_compat_shim_present_in_headroom_py` —
the shim is the FIX, this audit is the GATE. Without the audit,
a future toolchain bump in the prebuilt ORT artifacts (or any
other statically-linked C/C++ dep) could re-introduce a
post-floor symbol that our current shim doesn't cover. The audit
catches that at release time, before publish-pypi.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "audit_wheel_glibc_symbols.py" in content, (
"release.yml must invoke `scripts/audit_wheel_glibc_symbols.py` "
"on every Linux wheel before publish. Without it, regressions "
"of issue #355's bug class ship to PyPI silently."
)
assert "Audit wheel glibc symbols (Linux only)" in content, (
"audit step name has been renamed; update both this test and the workflow"
)
def test_release_workflow_has_smoke_import_wheel_gate() -> None:
"""STRUCTURAL INVARIANT: release.yml runs the just-built wheels
through `import headroom._core` on a matrix of representative
customer environments BEFORE publishing to PyPI / pushing to
GHCR / cutting a GitHub Release.
This is the X1 gate from the post-#355 hardening plan. Issue #355
plus its three follow-on hotfixes (#384/#385/#386) all share a
pattern: the wheel is technically valid (clippy passes, tests
pass, auditwheel is happy) but fails to import on a customer's
box because of a runtime symbol mismatch. Static gates can't
catch that — only actually loading the .so does.
Required matrix coverage:
- manylinux floor we promise (`manylinux_2_28_x86_64` and
`manylinux_2_28_aarch64`). If these fail, our manylinux tag
is a lie.
- At least one customer-representative glibc per arch (Ubuntu
LTS, the issue #355 reporter's environment).
- macOS native (Apple Silicon).
Required gating: `publish-pypi`, `publish-docker`, AND
`create-release` must all `needs:` smoke-import-wheels. A
smoke failure has to BLOCK publish, not just produce a
notification.
A future "remove this slow CI step that always passes anyway"
refactor — exactly the impulse that landed us PR #382's sdist
gap and PR #386's link-order surprise — fails this test at
PR time.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# The job itself must exist.
assert "\n smoke-import-wheels:" in content, (
"release.yml must define a `smoke-import-wheels` job. This is the "
"X1 gate that catches runtime symbol mismatches in the published "
"wheel before it hits PyPI. Issue #355 + #384/#385/#386 are the "
"canonical reason this gate exists."
)
# Required matrix entries — pin both the floor (manylinux_2_28)
# and at least one customer environment per arch.
required_matrix_substrings = [
# manylinux floor for x86_64 — pins what we promise customers.
'image: "quay.io/pypa/manylinux_2_28_x86_64"',
# manylinux floor for aarch64 — would have caught PR #386.
'image: "quay.io/pypa/manylinux_2_28_aarch64"',