From bcf8405e7a0ac24bc0c7beba2bf9d81bf645635f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 1 Jun 2026 18:17:49 +0200 Subject: [PATCH] Build architecture-specific macOS wheels --- .github/workflows/publish-pypi.yml | 36 +++-- .../fixed/macos-wheel-architectures.md | 2 + .../tests/test_validate_wheel_artifact.py | 151 ++++++++++++++++++ scripts/validate_wheel_artifact.py | 119 ++++++++++++++ 4 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixed/macos-wheel-architectures.md create mode 100644 interfaces/python/tests/test_validate_wheel_artifact.py create mode 100644 scripts/validate_wheel_artifact.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fea7726..eb99397 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -13,16 +13,24 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - manylinux: manylinux_2_17_x86_64.manylinux2014_x86_64 + wheel_platform: manylinux_2_17_x86_64.manylinux2014_x86_64 container: quay.io/pypa/manylinux_2_28_x86_64 + macos_deployment_target: "" - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - manylinux: manylinux_2_17_aarch64.manylinux2014_aarch64 + wheel_platform: manylinux_2_17_aarch64.manylinux2014_aarch64 container: quay.io/pypa/manylinux_2_28_aarch64 - - os: macos-14 + macos_deployment_target: "" + - os: macos-15-intel + target: x86_64-apple-darwin + wheel_platform: macosx_10_13_x86_64 + container: "" + macos_deployment_target: "10.13" + - os: macos-15 target: aarch64-apple-darwin - manylinux: "" + wheel_platform: macosx_11_0_arm64 container: "" + macos_deployment_target: "11.0" runs-on: ${{ matrix.os }} container: ${{ matrix.container || null }} @@ -48,6 +56,8 @@ jobs: targets: ${{ matrix.target }} - name: Build Rust binary + env: + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos_deployment_target }} run: cargo build --release --target ${{ matrix.target }} - name: Set up Python @@ -77,16 +87,16 @@ jobs: - name: Build wheel run: python -m build --wheel - - name: Retag Linux wheel as manylinux - if: matrix.manylinux != '' + - name: Retag wheel platform + run: | + python -m wheel tags --remove --platform-tag "${{ matrix.wheel_platform }}" dist/*.whl + ls -la dist/ + + - name: Validate wheel artifact run: | - cd dist - for whl in *linux*.whl; do - oldtag=$(echo "$whl" | sed -n 's/.*-\(linux_[a-z0-9_]*\)\.whl/\1/p') - newname=$(echo "$whl" | sed "s/$oldtag/${{ matrix.manylinux }}/") - mv "$whl" "$newname" - echo "Renamed $whl -> $newname" - done + python scripts/validate_wheel_artifact.py \ + --target "${{ matrix.target }}" \ + --wheel-platform "${{ matrix.wheel_platform }}" - uses: actions/upload-artifact@v7 with: diff --git a/changelog.d/fixed/macos-wheel-architectures.md b/changelog.d/fixed/macos-wheel-architectures.md new file mode 100644 index 0000000..ccfb095 --- /dev/null +++ b/changelog.d/fixed/macos-wheel-architectures.md @@ -0,0 +1,2 @@ +Publish architecture-specific macOS wheels so Intel Macs receive an x86_64 +Rust binary and Apple Silicon Macs receive an arm64 Rust binary. diff --git a/interfaces/python/tests/test_validate_wheel_artifact.py b/interfaces/python/tests/test_validate_wheel_artifact.py new file mode 100644 index 0000000..61cd021 --- /dev/null +++ b/interfaces/python/tests/test_validate_wheel_artifact.py @@ -0,0 +1,151 @@ +"""Tests for release wheel artifact validation.""" + +import importlib.util +import zipfile +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parents[3] +_SCRIPT = _REPO / "scripts" / "validate_wheel_artifact.py" +_SPEC = importlib.util.spec_from_file_location("validate_wheel_artifact", _SCRIPT) +validator = importlib.util.module_from_spec(_SPEC) +assert _SPEC.loader is not None +_SPEC.loader.exec_module(validator) + + +def _write_wheel(dist_dir: Path, filename: str, tag: str) -> Path: + dist_dir.mkdir() + wheel_path = dist_dir / filename + with zipfile.ZipFile(wheel_path, "w") as wheel: + wheel.writestr( + "policyengine_uk_compiled-0.30.0.dist-info/WHEEL", + f"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: {tag}\n", + ) + return wheel_path + + +def _write_binary(package_dir: Path) -> Path: + binary = package_dir / "bin" / "policyengine-uk-rust" + binary.parent.mkdir(parents=True) + binary.write_text("fake binary") + return binary + + +def test_validates_matching_macos_x86_64_wheel(tmp_path, monkeypatch): + dist_dir = tmp_path / "dist" + package_dir = tmp_path / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-macosx_10_13_x86_64.whl", + "py3-none-macosx_10_13_x86_64", + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path: "Mach-O 64-bit executable x86_64", + ) + + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target="x86_64-apple-darwin", + wheel_platform="macosx_10_13_x86_64", + ) + + +def test_validates_matching_macos_arm64_wheel(tmp_path, monkeypatch): + dist_dir = tmp_path / "dist" + package_dir = tmp_path / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-macosx_11_0_arm64.whl", + "py3-none-macosx_11_0_arm64", + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path: "Mach-O 64-bit executable arm64", + ) + + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target="aarch64-apple-darwin", + wheel_platform="macosx_11_0_arm64", + ) + + +def test_rejects_universal2_tag_for_arm64_only_binary(tmp_path, monkeypatch): + dist_dir = tmp_path / "dist" + package_dir = tmp_path / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-macosx_10_13_universal2.whl", + "py3-none-macosx_10_13_universal2", + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path: "Mach-O 64-bit executable arm64", + ) + + with pytest.raises(ValueError, match="expected tag"): + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target="aarch64-apple-darwin", + wheel_platform="macosx_11_0_arm64", + ) + + +def test_rejects_wheel_filename_and_metadata_disagreement(tmp_path, monkeypatch): + dist_dir = tmp_path / "dist" + package_dir = tmp_path / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-macosx_10_13_x86_64.whl", + "py3-none-macosx_11_0_arm64", + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path: "Mach-O 64-bit executable x86_64", + ) + + with pytest.raises(ValueError, match="metadata tags"): + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target="x86_64-apple-darwin", + wheel_platform="macosx_10_13_x86_64", + ) + + +def test_rejects_binary_architecture_mismatch(tmp_path, monkeypatch): + dist_dir = tmp_path / "dist" + package_dir = tmp_path / "pkg" + _write_wheel( + dist_dir, + "policyengine_uk_compiled-0.30.0-py3-none-macosx_10_13_x86_64.whl", + "py3-none-macosx_10_13_x86_64", + ) + _write_binary(package_dir) + monkeypatch.setattr( + validator, + "_binary_description", + lambda path: "Mach-O 64-bit executable arm64", + ) + + with pytest.raises(ValueError, match="Binary architecture"): + validator.validate_wheel_artifact( + dist_dir=dist_dir, + package_dir=package_dir, + target="x86_64-apple-darwin", + wheel_platform="macosx_10_13_x86_64", + ) diff --git a/scripts/validate_wheel_artifact.py b/scripts/validate_wheel_artifact.py new file mode 100644 index 0000000..edd7613 --- /dev/null +++ b/scripts/validate_wheel_artifact.py @@ -0,0 +1,119 @@ +"""Validate that a built wheel's tag matches its bundled Rust binary.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import zipfile +from pathlib import Path + + +EXPECTED_BINARY_ARCH = { + "x86_64-apple-darwin": ("x86_64",), + "aarch64-apple-darwin": ("arm64",), + "x86_64-unknown-linux-gnu": ("x86-64", "x86_64"), + "aarch64-unknown-linux-gnu": ("aarch64", "arm aarch64"), +} + + +def _wheel_files(dist_dir: Path) -> list[Path]: + return sorted(dist_dir.glob("*.whl")) + + +def _wheel_tags(wheel_path: Path) -> list[str]: + with zipfile.ZipFile(wheel_path) as wheel: + metadata_files = [ + name + for name in wheel.namelist() + if name.endswith(".dist-info/WHEEL") + ] + if len(metadata_files) != 1: + raise ValueError( + f"Expected exactly one WHEEL metadata file in {wheel_path}, " + f"found {len(metadata_files)}" + ) + metadata = wheel.read(metadata_files[0]).decode() + return [ + line.split(":", 1)[1].strip() + for line in metadata.splitlines() + if line.startswith("Tag:") + ] + + +def _binary_description(binary_path: Path) -> str: + result = subprocess.run( + ["file", str(binary_path)], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def validate_wheel_artifact( + *, + dist_dir: Path, + package_dir: Path, + target: str, + wheel_platform: str, +) -> None: + wheels = _wheel_files(dist_dir) + if len(wheels) != 1: + raise ValueError(f"Expected exactly one wheel in {dist_dir}, found {len(wheels)}") + + wheel = wheels[0] + expected_tag = f"py3-none-{wheel_platform}" + if expected_tag not in wheel.name: + raise ValueError(f"{wheel.name} does not contain expected tag {expected_tag}") + + tags = _wheel_tags(wheel) + if expected_tag not in tags: + raise ValueError(f"{wheel.name} metadata tags {tags} do not include {expected_tag}") + + binary_path = package_dir / "bin" / "policyengine-uk-rust" + if not binary_path.is_file(): + raise FileNotFoundError(f"Bundled Rust binary not found: {binary_path}") + + if target not in EXPECTED_BINARY_ARCH: + raise ValueError(f"Unsupported Rust target for validation: {target}") + description = _binary_description(binary_path).lower() + expected_arches = EXPECTED_BINARY_ARCH[target] + if not any(arch in description for arch in expected_arches): + raise ValueError( + f"Binary architecture does not match target {target}: " + f"expected one of {expected_arches}, got {description!r}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist-dir", type=Path, default=Path("dist")) + parser.add_argument( + "--package-dir", + type=Path, + default=Path("interfaces/python/policyengine_uk_compiled"), + ) + parser.add_argument("--target", required=True) + parser.add_argument("--wheel-platform", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + validate_wheel_artifact( + dist_dir=args.dist_dir, + package_dir=args.package_dir, + target=args.target, + wheel_platform=args.wheel_platform, + ) + except Exception as exc: + print(f"wheel artifact validation failed: {exc}", file=sys.stderr) + return 1 + print("wheel artifact validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())