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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 106 additions & 2 deletions .github/workflows/ios-asset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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` +
Expand All @@ -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
Expand Down Expand Up @@ -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<version>`
Expand All @@ -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`
Expand Down
26 changes: 24 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,17 @@ jobs:
# Without this they would be tarred and published as
# `ciris-server-<tag>-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-<tag>-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" .
Expand All @@ -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
Expand All @@ -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
Expand Down
186 changes: 186 additions & 0 deletions tools/build_ios_wheel.py
Original file line number Diff line number Diff line change
@@ -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:]))
Loading
Loading