From 0c467be73a415d5fe0cd9b584d8b266da8116e09 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 3 Sep 2026 01:02:26 -0500 Subject: [PATCH 1/3] #532: iOS wheels, and a gate that reads the binary instead of trusting the filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The premise needed measuring first The issue says "there is no simulator build anywhere". There is. `ios-asset.yml` has been cross-compiling BOTH slices since it was split out of ci.yml, and `ciris-server-v0.5.196-ios.tar.gz` (48 MB, cosign-signed) ships them: ios-device/_native.abi3.so LC_VERSION_MIN_IPHONEOS 10.0 ios-simulator/_native.abi3.so LC_BUILD_VERSION platform=iOSSimulator 14.0 Read off the published asset, not off the workflow. `file(1)` calls both "Mach-O 64-bit arm64 dynamically linked shared library" — byte-identically — so the only way to tell them apart is the load commands, which is also the reason nobody noticed the simulator slice was already there. What was missing is the WHEEL, and that is what this adds. ## The tag has to be true `IPHONEOS_DEPLOYMENT_TARGET` was never set, so the two slices took different floors — 10.0 and 14.0, same commit, same job. Invisible in a tarball, because nothing reads a minimum OS out of a tar member. It stops being invisible the moment those binaries ship as wheels: pip installs on the strength of the TAG, so the `ios_13_0_arm64_iphonesimulator` wheel this issue asks for would have installed on iOS 13 and failed at load. Pinned to 13.0 for both. ## Why the wheel is assembled and not `maturin build`ed I wrote this with `maturin build --target aarch64-apple-ios` first. ci.yml already says why that cannot work, in a comment above the job I was editing: maturin 1.13's platform check rejects a darwin→iOS cross-compile outright Confirmed rather than taken on trust — it fails before compiling anything, on linux→iOS too: "platform.system() in python, linux, and the rust target, Target { os: Ios, … }, don't match ಠ_ಠ". So `tools/build_ios_wheel.py` assembles the wheel from the slice `cargo build` already produces. The one thing it does NOT do is write METADATA: a hand-written one is a second spelling of the packaging rules that stops matching the other eight wheels the first time pyproject.toml moves. It runs `maturin pep517 write-dist-info` — the path pip drives for `prepare_metadata_for_build_wheel`, no compiler needed — and takes the dist-info verbatim, rewriting only WHEEL's `Tag:`. Checked against the published macosx_11_0_arm64 wheel's METADATA (via PEP 658): identical but for one README line the tree had legitimately moved. ## `tools/check_ios_wheel.py` Every other platform checks its own wheel by accident — a mislabelled manylinux wheel fails on the first import, on the machine that built it. iOS is the one 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 wheel is opened and its Mach-O load commands are read against its filename: sdk (`iphoneos` vs `iphonesimulator`), arch, minimum OS, and the `PyInit__native` export. Verified three ways, on this box, with no macOS: - eleven self-test cases over hand-built Mach-O headers, so the REFUSALS execute on every CI run rather than only when something is already wrong (wired into ci.yml for that reason); - against the real published v0.5.196 slices; - end-to-end: the assembler builds both wheels from those slices and the gate reads them back. RECORD hashes/sizes verified against every member, and `packaging.parse_wheel_filename` accepts both tags. Running the CI sequence with `IPHONEOS_DEPLOYMENT_TARGET=13.0` over TODAY's binaries reproduces the defect the pin fixes: the device wheel passes, and the simulator wheel is refused with "tag claims iOS 13.0 but the binary's minimum is 14.0". Two bugs the self-test found in the gate itself, both of which would have failed CORRECT wheels — the failure mode that gets a gate deleted: - `b"".split(b"\x00")` is `[b""]`, a truthy one-element set, so a STRIPPED binary's empty symbol table read as "exists and lacks PyInit__native". Every stripped wheel we intend to ship would have been refused. Empty entries are dropped now, and the export falls back to the dyld trie. - `minos < tag` was treated as an error. It is SAFE — the binary runs everywhere the tag admits — and only `minos > tag` can strand a device. ## Scope Release assets, not PyPI. The wheels are hashed into SHA256SUMS, cosign-signed and attached like every other asset, which is what CIRISAgent's iOS CI needs. PyPI is deliberately a follow-up: an unproven wheel in that publish fails the upload for the WHOLE matrix, and it fails after the tag is cut. The tarball is untouched and still built first — `update_substrate_libs.py` consumes it, and a new packaging lane must not be able to cost us the artifact that already works. x86_64 simulator is skipped: the runners are macos-14 (arm64) and so are the simulators CI drives. Easy to add — the xcframework carries the slice — when something actually needs it. STILL UNPROVEN, and only CI can prove it: that `IPHONEOS_DEPLOYMENT_TARGET=13.0` takes on the cargo iOS build, and that 13.0 links against Python-Apple-support's simulator dylib, which today carries 14.0. The gate makes either failure loud. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW --- .github/workflows/ci.yml | 10 + .github/workflows/ios-asset.yml | 61 +++++- .github/workflows/release.yml | 26 ++- tools/build_ios_wheel.py | 169 +++++++++++++++ tools/check_ios_wheel.py | 367 ++++++++++++++++++++++++++++++++ 5 files changed, 629 insertions(+), 4 deletions(-) create mode 100755 tools/build_ios_wheel.py create mode 100755 tools/check_ios_wheel.py 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..4dbf7edc 100644 --- a/.github/workflows/ios-asset.yml +++ b/.github/workflows/ios-asset.yml @@ -39,8 +39,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 +58,21 @@ 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, and pinning it here makes one number + # true of both slices, the tarball and the wheels alike. + # `tools/check_ios_wheel.py` refuses any wheel whose binary disagrees. + IPHONEOS_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.97.0 @@ -228,6 +243,48 @@ 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. + # + # 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, darwin, and the rust target … don't match" + # — which is exactly why the slice above 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 for the ios wheel) + run: pip install "maturin>=1.13,<2" + - name: assemble the ios wheel (${{ matrix.sdk }}) + run: >- + python3 tools/build_ios_wheel.py + --so "dist/${{ matrix.dir }}/_native.abi3.so" + --sdk ${{ matrix.sdk }} + --ios-min "$IPHONEOS_DEPLOYMENT_TARGET" + --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 (`--self-test`) so the refusals are exercised on every + # platform, not only when something is already wrong. + - name: verify the wheel tag against the 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-wheel-${{ matrix.dir }} + path: dist-wheel/*.whl + if-no-files-found: error # 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` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8cddaed..0bfadb0f 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-wheel-*) + 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..d9a9ddbc --- /dev/null +++ b/tools/build_ios_wheel.py @@ -0,0 +1,169 @@ +#!/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 + +# `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, ios_min: str, out_dir: Path) -> Path: + if not so.is_file(): + raise SystemExit(f"no such extension: {so}") + + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + di = dist_info(tmp) + name_version = di.name[: -len(".dist-info")] + version = name_version.split("-")[-1] + + major, minor = ios_min.split(".")[:2] + 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( + "--ios-min", + default="13.0", + help="minimum iOS, and the version in the tag. MUST match the binary's " + "own floor — tools/check_ios_wheel.py refuses a wheel where it does not.", + ) + 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.ios_min, 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:])) From 4ffa67add30893866161810789033529ddf95301 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 3 Sep 2026 01:30:24 -0500 Subject: [PATCH 2/3] The tag states the binary's own minimum, because CI proved one number cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 33721980725, the first real exercise of this lane: ios-device SUCCESS minos 10.0 -> 13.0, tagged ios_13_0_arm64_iphoneos ios-simulator FAILURE minos 14.0 unchanged, refused against an ios_13_0 tag `IPHONEOS_DEPLOYMENT_TARGET=13.0` did exactly what it was added for on the device slice. It did nothing to the simulator, and that is not a configuration mistake: 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 have genuinely different minimums because they are genuinely different platforms, and a single asserted number can only ever be right about one of them. So `build_ios_wheel.py` no longer takes `--ios-min`. It reads the floor out of the Mach-O it is packaging, which means the tag cannot drift from the artifact it names. The device wheel comes out `ios_13_0_arm64_iphoneos` and the simulator `ios_14_0_arm64_iphonesimulator`, each true of its own binary — and pip picks correctly, since the arm64 simulator only ever runs on a host that satisfies 14. The deployment target pin stays: it is what moved the device slice off its 10.0 default, which was the original defect. One parser, imported rather than repeated — the version in the tag and the version the gate reads back now come from the same `probe_macho`, so they cannot disagree. Two of them could, and the disagreement would be invisible until a wheel shipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW --- .github/workflows/ios-asset.yml | 14 +++++++++---- tools/build_ios_wheel.py | 35 ++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ios-asset.yml b/.github/workflows/ios-asset.yml index 4dbf7edc..11be8f86 100644 --- a/.github/workflows/ios-asset.yml +++ b/.github/workflows/ios-asset.yml @@ -69,9 +69,16 @@ jobs: # `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, and pinning it here makes one number - # true of both slices, the tarball and the wheels alike. - # `tools/check_ios_wheel.py` refuses any wheel whose binary disagrees. + # 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 @@ -266,7 +273,6 @@ jobs: python3 tools/build_ios_wheel.py --so "dist/${{ matrix.dir }}/_native.abi3.so" --sdk ${{ matrix.sdk }} - --ios-min "$IPHONEOS_DEPLOYMENT_TARGET" --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 diff --git a/tools/build_ios_wheel.py b/tools/build_ios_wheel.py index d9a9ddbc..3462d986 100755 --- a/tools/build_ios_wheel.py +++ b/tools/build_ios_wheel.py @@ -51,6 +51,12 @@ 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 @@ -81,17 +87,34 @@ def dist_info(tmp: Path) -> Path: return dirs[0] -def build(so: Path, sdk: str, arch: str, ios_min: str, out_dir: Path) -> Path: +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] - major, minor = ios_min.split(".")[:2] tag = f"{PY_ABI_TAG}-ios_{major}_{minor}_{arch}_{sdk}" wheel_name = f"{name_version.replace('-', '-', 1)}-{tag}.whl" @@ -151,17 +174,11 @@ def main(argv: list[str]) -> int: 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( - "--ios-min", - default="13.0", - help="minimum iOS, and the version in the tag. MUST match the binary's " - "own floor — tools/check_ios_wheel.py refuses a wheel where it does not.", - ) 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.ios_min, a.out) + build(a.so, a.sdk, a.arch, a.out) return 0 From 80d15012fe1c08051298942586b757371eb488d1 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 3 Sep 2026 01:55:08 -0500 Subject: [PATCH 3/3] =?UTF-8?q?Split=20the=20wheels=20into=20their=20own?= =?UTF-8?q?=20ubuntu=20job=20=E2=80=94=20a=20wheel=20gate=20must=20not=20c?= =?UTF-8?q?ost=20us=20the=20tarball?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote "a new packaging lane must not be able to cost us the artifact that already works" and then built one that could. Run 33721980725 proved it: the wheel gate correctly refused a mistagged simulator wheel, the matrix leg went red, and `ios-package` — which `needs: [ios-build]` — was SKIPPED. The tarball had built perfectly on both slices and the release would have lost it anyway. Putting the steps AFTER the slice upload was not enough; the artifacts existed, but the job that combines them never ran. So the wheels are their own job now, and it runs on UBUNTU. Nothing about assembling a zip and reading Mach-O load commands needs macOS — and macOS is the contended pool in this org, with the two `ios-build` legs already the largest consumer in the repo. `maturin pep517 write-dist-info` runs anywhere. The cross-compile still happens exactly once; the wheel job consumes the per-slice artifacts `ios-build` uploads. Net effect: a wheel failure is loud and costs nothing else, and the iOS lane's macOS footprint is unchanged rather than grown. Also renames the artifact to `ciris-server-ios-wheels` (one artifact, both wheels) and updates release.yml's copy-through case to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW --- .github/workflows/ios-asset.yml | 107 ++++++++++++++++++++++---------- .github/workflows/release.yml | 2 +- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ios-asset.yml b/.github/workflows/ios-asset.yml index 11be8f86..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: @@ -256,31 +260,86 @@ jobs: # 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` + # blob under the same key. This job builds RELEASE, and no other + # CIRISServer job uses an `ios-*` PLAT token — so there is nothing to + # collide with, and the next run (and CIRISAgent's refresh) restores a + # warm iOS target/ instead of cold-compiling the substrate. Persist saves + # from its iOS lane for exactly this reason. continue-on-error, so a + # missing package scope can never turn the build red. + - uses: CIRISAI/CIRISCache/save@v1 + if: github.ref == 'refs/heads/main' + continue-on-error: true + with: + key: ${{ steps.cachekey.outputs.key }} + token: ${{ secrets.GITHUB_TOKEN }} + # LRU-prune target/ to 4GB before tar (persist v1/ff52ca3): breaks the + # 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, darwin, and the rust target … don't match" - # — which is exactly why the slice above 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 + # "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 for the ios wheel) + - name: install maturin (dist-info only) run: pip install "maturin>=1.13,<2" - - name: assemble the ios wheel (${{ matrix.sdk }}) - run: >- - python3 tools/build_ios_wheel.py - --so "dist/${{ matrix.dir }}/_native.abi3.so" - --sdk ${{ matrix.sdk }} - --out dist-wheel + - 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 (`--self-test`) so the refusals are exercised on every - # platform, not only when something is already wrong. - - name: verify the wheel tag against the binary + # 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/ @@ -288,27 +347,9 @@ jobs: python3 tools/check_ios_wheel.py dist-wheel/*.whl - uses: actions/upload-artifact@v4 with: - name: ciris-server-ios-wheel-${{ matrix.dir }} + name: ciris-server-ios-wheels path: dist-wheel/*.whl if-no-files-found: error - # 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` - # blob under the same key. This job builds RELEASE, and no other - # CIRISServer job uses an `ios-*` PLAT token — so there is nothing to - # collide with, and the next run (and CIRISAgent's refresh) restores a - # warm iOS target/ instead of cold-compiling the substrate. Persist saves - # from its iOS lane for exactly this reason. continue-on-error, so a - # missing package scope can never turn the build red. - - uses: CIRISAI/CIRISCache/save@v1 - if: github.ref == 'refs/heads/main' - continue-on-error: true - with: - key: ${{ steps.cachekey.outputs.key }} - token: ${{ secrets.GITHUB_TOKEN }} - # LRU-prune target/ to 4GB before tar (persist v1/ff52ca3): breaks the - # fat-blob ratchet where superseded dep generations are re-saved forever. - max-size-mb: "4096" # 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bfadb0f..6302feea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -472,7 +472,7 @@ jobs: # 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-wheel-*) + ios-wheels) cp "$dir"/*.whl release/ continue ;;