Skip to content

#532: iOS wheels, and a gate that reads the binary instead of trusting the filename - #535

Open
emooreatx wants to merge 3 commits into
mainfrom
feat/ios-wheels-532
Open

#532: iOS wheels, and a gate that reads the binary instead of trusting the filename#535
emooreatx wants to merge 3 commits into
mainfrom
feat/ios-wheels-532

Conversation

@emooreatx

@emooreatx emooreatx commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.yml has been cross-compiling both slices, and ciris-server-v0.5.196-ios.tar.gz (48 MB, cosign-signed) carries them. Read off the published asset:

ios-device/_native.abi3.so       LC_VERSION_MIN_IPHONEOS 10.0
ios-simulator/_native.abi3.so    LC_BUILD_VERSION platform=iOSSimulator 14.0

file(1) prints Mach-O 64-bit arm64 dynamically linked shared library for 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_TARGET was 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 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 slices.

Why assembled, not maturin builded

I wrote this with maturin build --target aarch64-apple-ios first. ci.yml already 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:

💥 maturin failed
  Caused by: 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 — that would be a second spelling of the packaging rules, which 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.

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.py opens 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:

  1. 11 self-test cases over hand-built Mach-O headers, so the refusals execute on every CI run (wired into ci.yml).
  2. Against the real published v0.5.196 slices.
  3. End-to-end — the assembler builds both wheels from those slices and the gate reads them back. RECORD hashes and sizes verified against every member; 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:

ios_13_0_arm64_iphoneos.whl          ✓ tag matches the binary
ios_13_0_arm64_iphonesimulator.whl   ::error:: tag claims iOS 13.0 but the
    binary's minimum is 14.0. pip reads the TAG, so this installs on iOS 13.0
    and fails at load.

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 would have been refused.
  • minos < tag was treated as an error. It is safe — the binary runs everywhere the tag admits — and only minos > tag strands 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.py consumes 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.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 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, and ci.yml declares no types:, so adding the ci:full label does not by itself retrigger anything. Run 33721980725 is a workflow_dispatch on this branch, which satisfies the condition directly and exercises the new steps.

🤖 Generated with Claude Code

https://claude.ai/code/session_016KA4HGLvDLofp3Ejjw3FSW

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…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 emooreatx added the ci:full Run the full CI matrix (macOS + Windows) on this PR label Sep 3, 2026
emooreatx and others added 2 commits September 3, 2026 01:30
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Run the full CI matrix (macOS + Windows) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish ciris-server wheels for iOS (iphoneos + iphonesimulator)

1 participant