#532: iOS wheels, and a gate that reads the binary instead of trusting the filename - #535
Open
emooreatx wants to merge 3 commits into
Open
#532: iOS wheels, and a gate that reads the binary instead of trusting the filename#535emooreatx wants to merge 3 commits into
emooreatx wants to merge 3 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…g the filename
## 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW
emooreatx
force-pushed
the
feat/ios-wheels-532
branch
from
September 3, 2026 06:08
998797a to
0c467be
Compare
… cannot
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW
…st us the tarball 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #532 (wheels), and fixes a deployment-target defect found while measuring its premise.
The premise, measured first
The issue says "there is no simulator build anywhere". There is — and it ships.
ios-asset.ymlhas been cross-compiling both slices, andciris-server-v0.5.196-ios.tar.gz(48 MB, cosign-signed) carries them. Read off the published asset:file(1)printsMach-O 64-bit arm64 dynamically linked shared libraryfor both, byte-identically, which is probably why the simulator slice went unnoticed. If the iOS CI gate is blocked on a simulator binary, it is unblocked today — see the comment on #532.What was genuinely missing is the wheel.
The tag has to be true
IPHONEOS_DEPLOYMENT_TARGETwas never set, so the two slices took different floors — 10.0 and 14.0, from one commit in one job. Invisible in a tarball (nothing reads a minimum OS out of a tar member) and a correctness bug the moment the same binaries ship as wheels: pip installs on the strength of the tag, so theios_13_0_arm64_iphonesimulatorwheel this issue asks for would have installed on iOS 13 and failed at load. Pinned to 13.0 for both slices.Why assembled, not
maturin buildedI wrote this with
maturin build --target aarch64-apple-iosfirst.ci.ymlalready says why that cannot work, in a comment directly above the job I was editing — "maturin 1.13's platform check rejects a darwin→iOS cross-compile outright". Confirmed rather than trusted; it fails before compiling anything:So
tools/build_ios_wheel.pyassembles the wheel from the slicecargo buildalready produces. The one thing it does not do is write METADATA — that would be a second spelling of the packaging rules, which stops matching the other eight wheels the first timepyproject.tomlmoves. It runsmaturin pep517 write-dist-info(the path pip drives forprepare_metadata_for_build_wheel, no compiler needed) and takes the dist-info verbatim, rewriting onlyWHEEL'sTag:. Checked against the publishedmacosx_11_0_arm64wheel's METADATA via PEP 658: identical but for one README line the tree had legitimately moved.The gate
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
tools/check_ios_wheel.pyopens the wheel and reads the Mach-O load commands against the filename: sdk, arch, minimum OS,PyInit__native.Verified three ways on a box with no macOS:
ci.yml).packaging.parse_wheel_filenameaccepts both tags.Running the CI sequence with
IPHONEOS_DEPLOYMENT_TARGET=13.0over today's binaries reproduces the defect the pin fixes: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 lacksPyInit__native". Every stripped wheel would have been refused.minos < tagwas treated as an error. It is safe — the binary runs everywhere the tag admits — and onlyminos > tagstrands a device.Scope
Release assets, not PyPI — that is what CIRISAgent's iOS CI needs, and an unproven wheel in the PyPI publish fails the upload for the whole matrix, after the tag is cut. The tarball is untouched and still built first;
update_substrate_libs.pyconsumes it and a new packaging lane must not be able to cost us the artifact that already works. x86_64 simulator skipped (runners and simulators are both arm64); the xcframework carries the slice if it is ever needed.Still unproven, and only CI can prove it: that
IPHONEOS_DEPLOYMENT_TARGET=13.0takes 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 rather than silent.The iOS job is kept off the PR lane deliberately —
if: github.event_name != 'pull_request' || contains(…labels…, 'ci:full'), because the macOS pool is the scarce one. That is a condition, not a path filter, andci.ymldeclares notypes:, so adding theci:fulllabel does not by itself retrigger anything. Run 33721980725 is aworkflow_dispatchon this branch, which satisfies the condition directly and exercises the new steps.🤖 Generated with Claude Code
https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW