Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/fixed/macos-wheel-architectures.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 151 additions & 0 deletions interfaces/python/tests/test_validate_wheel_artifact.py
Original file line number Diff line number Diff line change
@@ -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",
)
119 changes: 119 additions & 0 deletions scripts/validate_wheel_artifact.py
Original file line number Diff line number Diff line change
@@ -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())