diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 095e23eb..cc5e8519 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,16 @@ jobs: - name: "Evidence manifest resolves (CC impl: tier)" if: runner.os == 'Linux' run: python3 tools/check_evidence.py + # The iOS wheel gate's own negative cases (CIRISServer#532). It runs for + # real in ios-asset.yml, on macOS, against wheels only that job produces — + # so on every other run of CI the code that REFUSES a mistagged wheel is + # never executed. The self-test builds Mach-O headers by hand, needs no + # toolchain, and takes milliseconds, so the refusals are exercised here + # too: a gate whose failure paths only run when something is already + # broken is a gate nobody has tested. + - name: "iOS wheel tag checker self-test" + if: runner.os == 'Linux' + run: python3 tools/check_ios_wheel.py --self-test - name: sccache stats if: always() run: sccache --show-stats diff --git a/.github/workflows/ios-asset.yml b/.github/workflows/ios-asset.yml index 3d65b5b9..fc09cbcc 100644 --- a/.github/workflows/ios-asset.yml +++ b/.github/workflows/ios-asset.yml @@ -26,6 +26,10 @@ on: Name of the packaged artifact (device + simulator slices in one tarball). Callers download this rather than hard-coding the string. value: ciris-server-ios + wheels: + description: >- + Name of the PEP 730 iOS wheel artifact (device + simulator). + value: ciris-server-ios-wheels jobs: ios-build: @@ -39,8 +43,8 @@ jobs: fail-fast: false matrix: include: - - { target: aarch64-apple-ios, dir: ios-device, lib_dir_out: device_lib_dir } - - { target: aarch64-apple-ios-sim, dir: ios-simulator, lib_dir_out: sim_lib_dir } + - { target: aarch64-apple-ios, dir: ios-device, lib_dir_out: device_lib_dir, sdk: iphoneos } + - { target: aarch64-apple-ios-sim, dir: ios-simulator, lib_dir_out: sim_lib_dir, sdk: iphonesimulator } env: # 3.10 is not arbitrary: the client's iOS app embeds CPython 3.10 # (CIRISClient's `iosApp` Resources carry `python3.10` + @@ -58,6 +62,28 @@ jobs: # default features stay ON (`default = ["pkcs11"]`), which is precisely # what `maturin build` compiles for the published wheel. IOS_PYO3_FEATURES: extension-module + # ── THE TAG HAS TO BE TRUE (CIRISServer#532) ────────────────────────── + # + # Unset, the two slices pick DIFFERENT floors from the same commit in the + # same job: measured on the published v0.5.196 asset, the device slice + # carries `LC_VERSION_MIN_IPHONEOS 10.0` and the simulator slice + # `LC_BUILD_VERSION minos 14.0`. That was invisible while the artifact was + # a tarball — nothing reads a floor out of a tar member — and becomes a + # correctness bug the moment the same binaries ship as wheels, because + # `pip` installs on the strength of the TAG. A wheel tagged `ios_13_0` + # around the 14.0 simulator slice installs on iOS 13 and fails at load. + # + # 13.0 is the floor the app targets. It moves the DEVICE slice off its + # 10.0 default — measured, run 33721980725: 10.0 before, 13.0 after. + # + # It does NOT move the simulator, and that is not a failure to configure. + # The arm64 iPhone simulator did not exist before iOS 14 (it arrived with + # Apple silicon), so the toolchain floors `aarch64-apple-ios-sim` at 14.0 + # and no deployment target lowers it. The two slices therefore carry + # genuinely different minimums, and each wheel's tag states its OWN — + # `build_ios_wheel.py` reads it out of the binary rather than being told. + # `tools/check_ios_wheel.py` refuses any wheel whose tag disagrees. + IPHONEOS_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.97.0 @@ -228,6 +254,12 @@ jobs: name: ciris-server-ios-${{ matrix.dir }} path: dist/${{ matrix.dir }}/_native.abi3.so if-no-files-found: error + # ── The PEP 730 wheel (CIRISServer#532) ──────────────────────────────── + # + # AFTER the tarball slice is uploaded, deliberately: the tarball is what + # CIRISAgent's `update_substrate_libs.py` already consumes, and a new + # packaging lane must not be able to cost us the artifact that works. + # # The ONE CI job that SAVES a CIRISCache blob, and it does not contradict # the "CI does NOT save" note on clippy-test above: that note is about # DEBUG target/ dirs colliding with conformance's RELEASE `-s` @@ -247,6 +279,78 @@ jobs: # fat-blob ratchet where superseded dep generations are re-saved forever. max-size-mb: "4096" + # ── The PEP 730 wheels (CIRISServer#532) ────────────────────────────────── + # + # A SEPARATE JOB, and on UBUNTU, for two reasons that were both learned the + # hard way in run 33721980725. + # + # 1. It must not be able to cost us the tarball. These steps started inside + # `ios-build`, and when the wheel gate refused a mistagged wheel the whole + # matrix leg went red — so `ios-package`, which `needs: [ios-build]`, was + # SKIPPED and the release lost the iOS asset that had built perfectly. A + # new packaging lane taking down the one that already works is exactly what + # this was supposed to avoid; splitting the job is what actually avoids it. + # + # 2. None of this needs macOS. Assembling a zip and reading Mach-O load + # commands is platform-independent, and macOS is the contended pool in this + # org — the two `ios-build` legs are already the largest consumer in the + # repo. `maturin pep517 write-dist-info` runs anywhere. + # + # Consumes the per-slice artifacts `ios-build` uploads, so the cross-compile + # happens exactly once. + ios-wheel: + name: ios wheels (PEP 730) + runs-on: ubuntu-latest + needs: [ios-build] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + # maturin is here for its DIST-INFO, not for a build. `maturin build + # --target aarch64-apple-ios` is refused before it compiles anything — + # "platform.system() in python, linux, and the rust target … don't match" + # — which is why the slice is a `cargo build` and is recorded as such in + # ci.yml. What maturin CAN do without a compiler is `pep517 + # write-dist-info`, the same path pip drives for + # `prepare_metadata_for_build_wheel`, and that keeps the packaging + # metadata single-sourced: a hand-written METADATA would be a second + # spelling that silently stops matching the other eight wheels the first + # time pyproject.toml moves. + - name: install maturin (dist-info only) + run: pip install "maturin>=1.13,<2" + - uses: actions/download-artifact@v4 + with: + pattern: ciris-server-ios-ios-* + path: slices/ + - name: assemble the wheels + run: | + set -euo pipefail + ls -R slices/ + python3 tools/build_ios_wheel.py \ + --so slices/ciris-server-ios-ios-device/_native.abi3.so \ + --sdk iphoneos --out dist-wheel + python3 tools/build_ios_wheel.py \ + --so slices/ciris-server-ios-ios-simulator/_native.abi3.so \ + --sdk iphonesimulator --out dist-wheel + # THE GATE. A wheel filename is a promise about a binary nobody opens, and + # iOS is the platform where no build machine and no CI job ever loads the + # artifact — the device that finds out is a phone, after distribution. So + # the tag is checked against the Mach-O load commands here, where it is + # cheap. Self-tested first, so the refusals are exercised even on a run + # where every wheel is fine. + - name: verify each wheel tag against its binary + run: | + set -euo pipefail + ls -la dist-wheel/ + python3 tools/check_ios_wheel.py --self-test + python3 tools/check_ios_wheel.py dist-wheel/*.whl + - uses: actions/upload-artifact@v4 + with: + name: ciris-server-ios-wheels + path: dist-wheel/*.whl + if-no-files-found: error + # Recombine the two parallel slices into the single tar the release publishes. # Mirrors persist's `ios-package`. Runs on EVERY push/PR, not just releases: # the recombine is validated long before a tag needs it, and `release.yml` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8cddaed..6302feea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -465,7 +465,17 @@ jobs: # Without this they would be tarred and published as # `ciris-server--ios-ios-device.tar.gz`: two assets nobody asked # for, named after an implementation detail. + # The PEP 730 iOS wheels (CIRISServer#532) are finished artifacts + # like the iOS tarball above: `.whl` IS the archive format, and pip + # resolves a wheel by its FILENAME. Taring one would produce + # `ciris-server--ios-wheel-ios-device.tar.gz` — an asset nobody + # can install, named after an implementation detail, which is the + # same mistake the `ios-ios-*` guard below already exists to prevent. case "$target" in + ios-wheels) + cp "$dir"/*.whl release/ + continue + ;; ios-ios-*) continue ;; esac tar -czvf "release/ciris-server-${{ github.ref_name }}-${target}.tar.gz" -C "$dir" . @@ -476,13 +486,22 @@ jobs: if ! ls release/*-ios.tar.gz >/dev/null 2>&1; then echo "::warning::No iOS asset in this release — see the ios-asset job. CIRISAgent's refresh-ios-substrate.yml will skip CIRISServer until one is published." fi - cd release && sha256sum *.tar.gz > SHA256SUMS + # Wheels are hashed beside the tarballs. Globbed through `ls` rather + # than passed to sha256sum directly: an unmatched `*.whl` stays + # literal under bash and would fail the step on a release that + # legitimately has no wheels (the iOS lane red, everything else fine) + # — turning a partial release into no release at all. + cd release + # shellcheck disable=SC2012 + sha256sum $(ls *.tar.gz *.whl 2>/dev/null) > SHA256SUMS - name: Install cosign uses: sigstore/cosign-installer@v3 - name: Sign artifacts (Sigstore keyless) run: | cd release - for f in *.tar.gz SHA256SUMS; do + # Same guard as the hashing step: `*.whl` must not become a literal + # argument when the iOS wheel lane produced nothing. + for f in $(ls *.tar.gz *.whl 2>/dev/null) SHA256SUMS; do cosign sign-blob --yes --output-signature "$f.sig" --output-certificate "$f.pem" "$f" done - name: Create GitHub Release @@ -492,6 +511,9 @@ jobs: release/*.tar.gz release/*.tar.gz.sig release/*.tar.gz.pem + release/*.whl + release/*.whl.sig + release/*.whl.pem release/SHA256SUMS release/SHA256SUMS.sig release/SHA256SUMS.pem diff --git a/tools/build_ios_wheel.py b/tools/build_ios_wheel.py new file mode 100755 index 00000000..3462d986 --- /dev/null +++ b/tools/build_ios_wheel.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Assemble a PEP 730 iOS wheel from a cross-compiled `_native.abi3.so`. + +## Why this exists instead of `maturin build --target aarch64-apple-ios` + +maturin refuses the cross-compile outright, and says so plainly: + + 💥 maturin failed + Caused by: Failed to get information from the python interpreter at python3 + Caused by: platform.system() in python, linux, and the rust target, + Target { os: Ios, ... }, don't match ಠ_ಠ + +That check fires before any compilation, on darwin→iOS as well as linux→iOS +(ci.yml records the same conclusion for persist's lane). So the iOS slice is +built by `cargo build --lib` with the PyO3 cross env — which `ios-asset.yml` +already does, and has done for every release — and the wheel is assembled here +from the result. + +## The metadata is maturin's, not ours + +The one thing worth being careful about: a hand-written METADATA is a SECOND +spelling of the packaging rules, and the moment `pyproject.toml` changes, the +iOS wheel describes a different package from the other eight. So this does not +write METADATA. It runs `maturin pep517 write-dist-info`, which is the same code +path pip drives for `prepare_metadata_for_build_wheel` and needs no compiler, and +takes the dist-info verbatim. Checked against the published +`ciris_server-0.5.196-cp310-abi3-macosx_11_0_arm64.whl`: byte-identical but for +one README line the tree had legitimately moved since that release. + +Only `WHEEL` is rewritten, because only its `Tag:` is platform-specific. + +Usage: + tools/build_ios_wheel.py \\ + --so dist/ios-device/_native.abi3.so \\ + --sdk iphoneos --out dist-wheel +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import hashlib +import io +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# ONE Mach-O parser in this repo. The tag's version comes from the binary, and +# the gate reads the binary back — if those were two parsers they could disagree, +# and the disagreement would be invisible until a wheel shipped. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_ios_wheel import probe_macho # noqa: E402 + +# `cp310-abi3` matches every other wheel in the matrix: pyo3's `abi3-py310` +# means one wheel serves CPython 3.10+. Kept beside the iOS bits rather than +# derived, because a wrong ABI tag here would be as silent as a wrong platform +# one — and the source of truth for it is Cargo.toml's pyo3 feature, which this +# script has no business re-reading. +PY_ABI_TAG = "cp310-abi3" + + +def _sha256_b64(data: bytes) -> str: + """RECORD hashes are urlsafe-base64 of the digest, with `=` padding stripped.""" + digest = hashlib.sha256(data).digest() + return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def dist_info(tmp: Path) -> Path: + """Ask maturin for the dist-info. No compilation, no second spelling.""" + out = tmp / "dist-info" + out.mkdir() + subprocess.run( + ["maturin", "pep517", "write-dist-info", "--metadata-directory", str(out)], + cwd=REPO, + check=True, + stdout=subprocess.DEVNULL, + ) + dirs = list(out.glob("*.dist-info")) + if len(dirs) != 1: + raise SystemExit(f"expected one .dist-info from maturin, got {dirs}") + return dirs[0] + + +def build(so: Path, sdk: str, arch: str, out_dir: Path) -> Path: + if not so.is_file(): + raise SystemExit(f"no such extension: {so}") + + # THE TAG STATES THE BINARY'S OWN MINIMUM, read out of its load commands + # rather than passed in beside it. + # + # It was an argument at first, pinned to `IPHONEOS_DEPLOYMENT_TARGET`, and CI + # proved that wrong on the first run: with the env var set to 13.0 the device + # slice moved to 13.0 and the SIMULATOR slice stayed at 14.0. Not a + # misconfiguration — the arm64 iPhone simulator did not exist before iOS 14 + # (it arrived with Apple silicon), so the toolchain floors that target at + # 14.0 and no env var lowers it. The two slices have genuinely different + # minimums because they are genuinely different platforms, and a single + # asserted number can only be right about one of them. + # + # Deriving it means the tag cannot drift from the artifact it names. The + # deployment target still does real work — it is what moved the device slice + # off its 10.0 default — it just no longer has to be restated here. + info = probe_macho(so.read_bytes()) + major, minor = info["minos"][0], info["minos"][1] + + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + di = dist_info(tmp) + name_version = di.name[: -len(".dist-info")] + version = name_version.split("-")[-1] + + tag = f"{PY_ABI_TAG}-ios_{major}_{minor}_{arch}_{sdk}" + wheel_name = f"{name_version.replace('-', '-', 1)}-{tag}.whl" + + # Only the Tag line is platform-specific; everything else maturin wrote + # stays exactly as it wrote it. + (di / "WHEEL").write_text( + "Wheel-Version: 1.0\n" + f"Generator: maturin via {Path(__file__).name}\n" + "Root-Is-Purelib: false\n" + f"Tag: {tag}\n" + ) + + records: list[tuple[str, str, int]] = [] + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + + def add(arcname: str, data: bytes) -> None: + zf.writestr(arcname, data) + records.append((arcname, _sha256_b64(data), len(data))) + + # The hand-written Python package, exactly as `python-source` says. + # `__pycache__` is excluded: shipping a host interpreter's .pyc into + # a wheel for a DIFFERENT platform is at best dead weight and at + # worst a stale import shadowing the real module. + pysrc = REPO / "python" + for f in sorted(pysrc.rglob("*")): + if not f.is_file() or "__pycache__" in f.parts: + continue + add(str(f.relative_to(pysrc)).replace("\\", "/"), f.read_bytes()) + + # The compiled extension, at the path `ciris_server/__init__.py` + # imports (`from ._native import *`). + add("ciris_server/_native.abi3.so", so.read_bytes()) + + for f in sorted(di.rglob("*")): + if f.is_file(): + rel = f.relative_to(di.parent) + add(str(rel).replace("\\", "/"), f.read_bytes()) + + # RECORD lists every member and itself, hashless — PEP 427. + rec = io.StringIO() + w = csv.writer(rec, lineterminator="\n") + for arcname, digest, size in records: + w.writerow([arcname, digest, size]) + w.writerow([f"{name_version}.dist-info/RECORD", "", ""]) + zf.writestr(f"{name_version}.dist-info/RECORD", rec.getvalue()) + + out_dir.mkdir(parents=True, exist_ok=True) + dest = out_dir / wheel_name + dest.write_bytes(buf.getvalue()) + print(f"✓ {dest} ({dest.stat().st_size:,} bytes, version {version})") + return dest + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--so", required=True, type=Path, help="the cross-compiled _native.abi3.so") + ap.add_argument("--sdk", required=True, choices=["iphoneos", "iphonesimulator"]) + ap.add_argument("--arch", default="arm64") + ap.add_argument("--out", required=True, type=Path) + a = ap.parse_args(argv) + if not shutil.which("maturin"): + raise SystemExit("maturin is not on PATH; it generates the dist-info") + build(a.so, a.sdk, a.arch, a.out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/check_ios_wheel.py b/tools/check_ios_wheel.py new file mode 100755 index 00000000..c9464fc0 --- /dev/null +++ b/tools/check_ios_wheel.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Verify an iOS wheel's PEP 730 tag against the Mach-O binary inside it. + +A wheel filename is a PROMISE about a binary nobody opens. For every other +platform the promise is cheap to check by accident — a manylinux wheel that is +secretly a macOS build fails loudly on the first import, on the machine that +built it. iOS is the one platform where nothing on the build machine, and +nothing in CI, ever loads the artifact: the device that finds out is a phone, +after distribution. + +The two ways to get it wrong are both silent and both one character apart: + +1. **The sdk is wrong** — a device (`iphoneos`) binary shipped under an + `iphonesimulator` tag, or the reverse. `pip` installs it happily; the + simulator then refuses to load a Mach-O whose platform is `iOS` rather than + `iOSSimulator`, and the CI job that was supposed to prove the app works + reports a dyld error instead. `file(1)` will not tell you: both slices print + `Mach-O 64-bit arm64 dynamically linked shared library`, byte-identically. + The difference lives in a load command. + +2. **The minimum version is a lie** — the tag says `ios_13_0`, the binary says + `LC_BUILD_VERSION minos 14.0`. `pip` reads the TAG to decide the wheel is + installable, so it installs on iOS 13 and the module fails at load. This is + not hypothetical here: before this check existed, and with + `IPHONEOS_DEPLOYMENT_TARGET` unset, our device slice carried a 10.0 floor and + our simulator slice a 14.0 floor — from the same commit, in the same job. + +So: open the wheel, read the load commands, and refuse a tag the binary does not +support. + +Usage: + tools/check_ios_wheel.py dist/ciris_server-*-ios_*.whl [...] + tools/check_ios_wheel.py --self-test +""" + +from __future__ import annotations + +import re +import struct +import sys +import zipfile +from pathlib import Path + +# Mach-O, 64-bit little-endian. The slices we ship are thin (one arch per +# wheel), so a fat header is a packaging mistake worth naming rather than +# silently walking. +MH_MAGIC_64 = 0xFEEDFACF +FAT_MAGIC = 0xCAFEBABE +FAT_MAGIC_64 = 0xCAFEBABF + +CPU_TYPE = {0x0100000C: "arm64", 0x01000007: "x86_64", 0x0000000C: "arm"} + +# `arm64` is the PEP 730 spelling; `aarch64` is the same machine under the name +# the Rust target triple uses. Normalising here keeps the gate from failing a +# CORRECT wheel over a synonym — the failure mode that gets a gate deleted. +ARCH_ALIASES = {"aarch64": "arm64", "amd64": "x86_64"} + +LC_SYMTAB = 0x02 +LC_VERSION_MIN_IPHONEOS = 0x25 +LC_BUILD_VERSION = 0x32 + +# `platform` in LC_BUILD_VERSION (mach-o/loader.h). +PLATFORM = { + 1: "macOS", + 2: "iOS", + 3: "tvOS", + 4: "watchOS", + 5: "bridgeOS", + 6: "macCatalyst", + 7: "iOSSimulator", + 8: "tvOSSimulator", + 9: "watchOSSimulator", + 10: "driverKit", +} + +# The sdk half of a PEP 730 tag, and the Mach-O platforms that satisfy it. +# +# `iphoneos` accepts LC_VERSION_MIN_IPHONEOS as well as LC_BUILD_VERSION +# platform=iOS: the older load command is what the linker emits for a device +# build with a low deployment target, and it carries the same meaning. There is +# no such legacy spelling for the simulator — a simulator slice ALWAYS carries +# LC_BUILD_VERSION platform=iOSSimulator — which is exactly why the two are not +# interchangeable and why a missing platform record is treated as a device +# build, never as a simulator one. +SDK_PLATFORMS = { + "iphoneos": {"iOS"}, + "iphonesimulator": {"iOSSimulator"}, +} + +TAG_RE = re.compile( + r"^(?P.+?)-(?P.+?)-(?P[^-]+)-(?P[^-]+)-" + r"ios_(?P\d+)_(?P\d+)_(?P.+?)_(?Piphoneos|iphonesimulator)\.whl$" +) + + +class WheelProblem(Exception): + """A wheel whose filename and contents disagree.""" + + +def _version(raw: int) -> tuple[int, int, int]: + """Mach-O packs a version as xxxx.yy.zz in 32 bits.""" + return (raw >> 16, (raw >> 8) & 0xFF, raw & 0xFF) + + +def probe_macho(data: bytes) -> dict: + """Read arch, platform, minimum OS and exported symbols out of a Mach-O.""" + (magic,) = struct.unpack_from(" list[str]: + """Return the notes for a good wheel; raise `WheelProblem` for a bad one.""" + m = TAG_RE.match(path.name) + if not m: + raise WheelProblem( + f"{path.name} is not a PEP 730 iOS wheel filename " + "(expected …-ios____.whl)" + ) + want_sdk = m["sdk"] + want_arch = m["arch"] + want_min = (int(m["major"]), int(m["minor"])) + + with zipfile.ZipFile(path) as zf: + sos = [n for n in zf.namelist() if n.endswith(".so") or n.endswith(".dylib")] + if not sos: + raise WheelProblem( + "the wheel carries no compiled extension. An iOS wheel whose " + "whole purpose is the native module is worse empty than absent: " + "it installs, imports, and fails on the first call." + ) + if len(sos) > 1: + raise WheelProblem(f"expected exactly one extension, found {len(sos)}: {sos}") + member = sos[0] + info = probe_macho(zf.read(member)) + + problems = [] + allowed = SDK_PLATFORMS[want_sdk] + if info["platform"] not in allowed: + other = "iphonesimulator" if want_sdk == "iphoneos" else "iphoneos" + problems.append( + f"tag says `{want_sdk}` but the binary is platform={info['platform']} " + f"— this is an `{other}` build under an `{want_sdk}` tag. It will " + f"install and then fail to load." + ) + want_arch = ARCH_ALIASES.get(want_arch, want_arch) + if ARCH_ALIASES.get(info["arch"], info["arch"]) != want_arch: + problems.append(f"tag says arch `{want_arch}` but the binary is `{info['arch']}`") + notes = [] + if info["minos"] is not None and info["minos"][:2] > want_min: + got = ".".join(str(x) for x in info["minos"][:2]) + want = ".".join(str(x) for x in want_min) + problems.append( + f"tag claims iOS {want} but the binary's minimum is {got}. pip reads " + f"the TAG, so this installs on iOS {want} and fails at load. Set " + f"IPHONEOS_DEPLOYMENT_TARGET={want} for the build, or tag the wheel {got}." + ) + elif info["minos"] is not None and info["minos"][:2] < want_min: + # SAFE, and still worth saying. The binary runs everywhere the tag + # admits, so nothing breaks — but the tag is the narrower promise, and a + # floor nobody chose is usually a deployment target nobody set. Ours was + # exactly that: device 10.0, simulator 14.0, same commit, same job. + got = ".".join(str(x) for x in info["minos"][:2]) + want = ".".join(str(x) for x in want_min) + notes.append( + f" note binary supports iOS {got}, tag only claims {want} " + f"(safe; tag is the narrower promise)" + ) + # `--strip` can leave LC_SYMTAB empty while the module is still perfectly + # exported through the dyld export trie — so an empty symbol table is not + # evidence of a missing symbol, and treating it as such would red-flag every + # stripped wheel we intend to ship. Fall back to the raw image, where the + # trie stores the name as a string. + if info["symbols"]: + exported = b"_PyInit__native" in info["symbols"] + else: + exported = info["raw_has_pyinit"] + if not exported: + problems.append( + "the extension does not export PyInit__native — `from ._native import *` " + "would fail at import" + ) + + if problems: + raise WheelProblem("; ".join(problems)) + + minos = ".".join(str(x) for x in info["minos"][:2]) + return [ + f"{path.name}", + f" member {member}", + f" platform {info['platform']} arch {info['arch']} minos {minos}", + *notes, + ] + + +def _self_test() -> int: + """Prove the checker fails on the two mistakes it exists to catch. + + A gate that has only ever been run against good input is a gate nobody has + tested. These build Mach-O headers by hand — no toolchain, no macOS — so the + negative cases are exercised on every platform this repo's CI runs on. + """ + import io + import tempfile + + def stripped(platform: int, minos: tuple[int, int], with_pyinit: bool = True) -> bytes: + """LC_SYMTAB present but EMPTY, as `--strip` leaves it; the export name + (when present) lives in the trailing image the way the export trie does.""" + body = struct.pack(" bytes: + strtab = b"\x00" + (b"_PyInit__native\x00" if with_pyinit else b"_other\x00") + # header + LC_BUILD_VERSION(24) + LC_SYMTAB(24), then the string table + stroff = 32 + 24 + 24 + body = struct.pack(" Path: + p = tmp / name + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("ciris_server/_native.abi3.so", blob) + p.write_bytes(buf.getvalue()) + return p + + DEVICE, SIM = 2, 7 + cases = [ + ("good device", "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(DEVICE, (13, 0)), None), + ("good simulator", "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphonesimulator.whl", + macho(SIM, (13, 0)), None), + ("device binary under a simulator tag", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphonesimulator.whl", + macho(DEVICE, (13, 0)), "under an `iphonesimulator` tag"), + ("simulator binary under a device tag", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(SIM, (13, 0)), "under an `iphoneos` tag"), + ("minos above the tag", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(DEVICE, (14, 0)), "installs on iOS 13.0 and fails at load"), + ("minos below the tag is SAFE, not an error", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(DEVICE, (12, 0)), None), + ("stripped wheel whose symtab is empty but IS exported", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + stripped(DEVICE, (13, 0)), None), + ("stripped wheel that is genuinely missing the export", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + stripped(DEVICE, (13, 0), with_pyinit=False), "does not export PyInit__native"), + ("aarch64 spelled in the tag is the same machine as arm64", + "ciris_server-0.1-cp310-abi3-ios_13_0_aarch64_iphoneos.whl", + macho(DEVICE, (13, 0)), None), + ("wrong arch", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(DEVICE, (13, 0), cputype=0x01000007), "arch `arm64` but the binary is `x86_64`"), + ("no PyInit__native", + "ciris_server-0.1-cp310-abi3-ios_13_0_arm64_iphoneos.whl", + macho(DEVICE, (13, 0), with_pyinit=False), "does not export PyInit__native"), + ] + + failures = 0 + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + for label, name, blob, expect in cases: + p = wheel(tmp, name, blob) + try: + check_wheel(p) + got = None + except WheelProblem as e: + got = str(e) + if expect is None and got is not None: + print(f" ✗ {label}: expected PASS, got refusal: {got}") + failures += 1 + elif expect is not None and (got is None or expect not in got): + print(f" ✗ {label}: expected a refusal mentioning {expect!r}, got {got!r}") + failures += 1 + else: + print(f" ✓ {label}") + print("self-test:", "PASS" if not failures else f"{failures} FAILED") + return 1 if failures else 0 + + +def main(argv: list[str]) -> int: + if "--self-test" in argv: + return _self_test() + paths = [Path(a) for a in argv if not a.startswith("-")] + if not paths: + print(__doc__) + return 2 + rc = 0 + for p in paths: + try: + for line in check_wheel(p): + print(line) + print(" ✓ tag matches the binary") + except WheelProblem as e: + print(f"::error::{p.name}: {e}") + rc = 1 + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))