This branch redesigns the Apple CI cache architecture by splitting the monolithic apple_host_sanity pre-merge lane into two independent, compile-only lanes: apple_desktop_compile and apple_ios_compile. The mutable iOS build number is dropped from the cache key in favor of deterministic Nix-built artifacts (appleDesktopCompile and appleIosXcframework packages). A new nix/ci/apple-rust.nix derivation validates workspace dependency closure at build time using cargo tree, ensuring the Nix fileset never silently omits real build dependencies. The remote runner protocol gains per-scope isolation (compile vs runtime), a stable content-addressed cache key script, and a centralized ci/apple-host-assets.toml that pins Xcode version, iOS runtime, and simulator device in one place. Supporting tooling (ios-runtime-doctor, ios-sim-ensure, apple-host-asset, xcode-run) and documentation are updated to match the new two-lane contract.
Tutorial Steps
Centralize Apple host asset pins in a TOML manifest
Intent: Replace scattered hard-coded Xcode and iOS runtime version strings with a single source of truth that both Nix expressions and shell scripts can consume.
A new ci/apple-host-assets.toml file becomes the canonical pin for four Apple CI parameters: xcode_version, ios_runtime, ios_device_type, and ios_device_name.
In flake.nix, the previously hard-coded xcodeVersion = "26.2" is replaced by reading the TOML at evaluation time via builtins.fromTOML. The bootstrap script (pikaci-apple-host-bootstrap.sh) now shells out to a new tools/apple-host-asset helper to read these values instead of embedding them inline.
This means changing the Xcode or iOS simulator version for CI is now a single-file edit to ci/apple-host-assets.toml, and every consumer — Nix, shell scripts, cache key computation — picks it up automatically.
Add the Nix apple-rust.nix derivation with dependency closure validation
Intent: Create hermetic, cacheable Nix packages for desktop compilation and iOS xcframework generation that validate their workspace dependency closures at build time.
The new nix/ci/apple-rust.nix defines a mkAppleRustPackage builder that wraps buildRustPackage with Xcode tooling and an embedded Python dependency-closure validator.
The validator runs cargo tree over every target package and asserts that only the declared allowedWorkspacePackages appear as transitive workspace dependencies. If the Nix fileset omits a real dependency, the build fails immediately with a clear error message — catching drift between the fileset and Cargo workspace that would otherwise surface as a confusing compile error.
Two packages are exposed in flake.nix:
appleDesktopCompile — compiles pika-desktop test binaries via cargo-with-xcode test --no-run, using a minimal appleDesktopWorkspaceSrc fileset.
appleIosXcframework — runs ios-build ios-xcframework and copies out Bindings/, NSEBindings/, ShareBindings/, and Frameworks/, using the broader appleIosWorkspaceSrc fileset.
Both filesets share a workspaceCrateManifestFiles list so that Cargo.toml stubs are generated for members not included in the fileset, keeping the workspace metadata valid.
Split apple_host_sanity into apple_desktop_compile and apple_ios_compile forge lanes
Intent: Replace the single blocking pre-merge Apple lane with two independent lanes that can run concurrently under a shared concurrency group, each with precisely scoped file-trigger paths.
@@ -289,6 +289,20 @@
+ "apple_desktop_compile" => Ok(host_shell_target_spec(
+ "apple_desktop_compile",
+ "Run the Apple desktop compile lane via pikaci",
+ &[],
+ 3600,
+ "./scripts/pikaci-apple-remote.sh run --just-recipe apple-host-desktop-compile",
+ )),
+ "apple_ios_compile" => Ok(host_shell_target_spec(
In forge_lanes.rs, the single apple_host_sanity branch lane is replaced by two lanes:
apple_desktop_compile — triggers on desktop Rust crate paths, pika-desktop, pika-cloud, Nix files, and the new ci/apple-host-assets.toml. Runs just apple-host-desktop-compile.
apple_ios_compile — triggers on iOS-specific paths (ios/**, pika-nse, pika-share, scripts/ios-build, simulator tooling). Runs just apple-host-ios-compile.
Both lanes share the concurrency group apple-compile, so the Mac mini schedules them without conflict. The nightly bundle lane's concurrency group is updated from apple-host to apple-runtime to avoid collision.
In targets.rs, corresponding host_shell_target_spec entries are added for both new targets, each invoking the appropriate just recipe via pikaci-apple-remote.sh. The original apple_host_sanity target spec is retained for backward compatibility.
Implement the just recipes for the two compile lanes
Intent: Wire the Nix-backed compile packages into actionable just recipes that CI can invoke, and restructure the existing sanity/bundle recipes accordingly.
apple-host-desktop-compile simply runs nix build .#appleDesktopCompile --no-link inside the apple-host dev shell, recording the phase timing.
apple-host-ios-compile is more involved:
Builds .#appleIosXcframework and captures the Nix store output path.
Computes a content-addressed cache key via ./scripts/apple-host-cache-key ios-generic-sim.
Derives a PIKA_IOS_DERIVED_DATA_PATH from that cache key so Xcode derived data is reused across identical inputs.
Runs ./scripts/ios-build ios-build-generic-sim with the prebuilt xcframework result injected via PIKACI_APPLE_IOS_XCFRAMEWORK_RESULT.
The existing apple-host-sanity recipe becomes a thin wrapper calling both compile lanes. The apple-host-bundle recipe is updated to invoke the sanity wrapper first, then runs desktop-ui-test as a separate phase (previously it was the entire sanity lane), and passes the Nix-built xcframework into the iOS UI test step.
Add the content-addressed cache key script
Intent: Provide a deterministic, content-addressed cache key that replaces the mutable iOS build number previously used in cache keys.
Affected files: scripts/apple-host-cache-key
Evidence
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+kind="${1:-}"
+
+emit_path_hashes() {
+ local path
+ for path in "$@"; do
+ if [[ -f "$path" ]]; then
+ printf 'path=%s\n' "$path"
+ shasum -a 256 "$path"
+ elif [[ -d "$path" ]]; then
+ while IFS= read -r -d '' file; do
The new scripts/apple-host-cache-key script computes a SHA-256 cache key from:
The kind argument (e.g., ios-generic-sim)
Xcode version and iOS runtime version (read from apple-host-asset)
Content hashes of flake.nix, flake.lock, and ci/apple-host-assets.toml
For ios-generic-sim: the Nix store path of the prebuilt xcframework (PIKACI_APPLE_IOS_XCFRAMEWORK_RESULT), plus content hashes of the iOS project files, build scripts, and tooling
All inputs are piped through shasum -a 256 to produce a single deterministic hash. This replaces the previous approach where the iOS build number (which changes on every commit) was baked into the cache key, causing unnecessary cache misses. Now the cache key changes only when the actual build inputs change.
Add ios-build-generic-sim subcommand and prebuilt xcframework sync
Intent: Enable the iOS build script to consume prebuilt Nix xcframework outputs and perform a generic-simulator compile without running tests.
Affected files: scripts/ios-build
Evidence
@@ -31,6 +31,44 @@
+sync_prebuilt_ios_xcframework_result() {
+ local result_path="${PIKACI_APPLE_IOS_XCFRAMEWORK_RESULT:-}"
+ if [[ -z "$result_path" ]]; then
+ return 1
+ fi
+ if [[ ! -d "$result_path/Frameworks" ]]; then
+ echo "error: prebuilt iOS xcframework result missing Frameworks/: $result_path" >&2
The scripts/ios-build script gains two key additions:
sync_prebuilt_ios_xcframework_result() — When PIKACI_APPLE_IOS_XCFRAMEWORK_RESULT is set, this function copies the Nix store outputs (Bindings/, NSEBindings/, ShareBindings/, Frameworks/) into the working tree, making them writable. The existing run_ios_xcframework() function is patched to short-circuit via this sync when the environment variable is present, avoiding a redundant rebuild.
run_ios_generic_simulator_xcodebuild() — A new xcodebuild invocation targeting generic/platform=iOS Simulator with CODE_SIGNING_ALLOWED=NO. Unlike the existing simulator build, this uses a configurable PIKA_IOS_DERIVED_DATA_PATH (defaulting to ios/build-generic) and is intentionally compile-only — no test execution.
ios-build-generic-sim — A new top-level subcommand composing xcframework → xcodeproj → generic simulator build.
Restructure the remote runner protocol with execution scopes
Intent: Isolate compile-scoped and runtime-scoped remote runs into separate directory trees so they maintain independent caches, locks, and prepared worktrees.
All remote paths (runs/, prepared/, repo.git, shared-target/, run.lock) are now nested under scopes/<execution_scope>/ instead of being flat under the remote root. This means compile lanes and runtime lanes have independent locks and don't block each other, and their Cargo target directories don't interfere.
The prepare profile satisfaction logic is also updated from a simple numeric rank comparison to an explicit compatibility matrix: desktop is satisfied by desktop, compile, or bundle; ios is satisfied by ios, compile, or bundle; compile requires compile or bundle; and bundle requires bundle exactly.
The run ID format is updated to include the sanitized recipe name and PID, making concurrent runs distinguishable.
Update the prepare script for new profiles and Nix-first builds
Intent: Align the apple-host-prepare.sh prewarm phases with the new lane structure, replacing cargo-based preparation with Nix builds.
The prepare script's profile system expands from three profiles (generic, sanity, bundle) to five (generic, desktop, ios, compile, bundle).
The old sanity profile ran cargo-with-xcode test -p pika-desktop --no-run to prewarm compiled test binaries. The new desktop and ios profiles instead invoke Nix builds (appleDesktopCompile and appleIosXcframework respectively), and the compile profile runs both.
For the bundle profile, the iOS xcframework Nix build runs early in the prepare phase, and its store path is captured in ios_xcframework_result. This path is then passed via PIKACI_APPLE_IOS_XCFRAMEWORK_RESULT to the later ios-build-for-testing step, which can skip the redundant xcframework build. The standalone ios-xcframework just recipe call that previously existed in the bundle prepare phase is removed entirely.
Add iOS runtime doctor and simulator ensure tooling to bootstrap
Intent: Ensure the CI bootstrap process validates and installs the pinned iOS simulator runtime, preventing runtime-missing failures during the iOS compile lane.
@@ -221,9 +222,40 @@
+ensure_ios_runtime() {
+ if [[ "$check_only" -eq 1 ]]; then
+ say "verifying pinned iOS simulator runtime: $ios_runtime"
+ else
+ say "ensuring pinned iOS simulator runtime: $ios_runtime"
+ fi
+ (
+ cd "$repo_root"
+ export PIKA_XCODE_INSTALL_PROMPT=0
+ if [[ "$check_only" -eq 1 ]]; then
+ nix --extra-experimental-features 'nix-command flakes' \
+ develop .#apple-host \
+ -c bash -lc '
+ set -euo pipefail
+ if ! ./tools/ios-runtime-doctor --quiet-check; then
The bootstrap script gains a new ensure_ios_runtime() step that runs after Xcode and dev shell verification. In check-only mode it validates the runtime is installed; in full mode it calls ./tools/ios-runtime-doctor --ensure to install it if missing.
The tools/ios-runtime-doctor and tools/ios-sim-ensure scripts (new files in this branch) read the pinned runtime and device configuration from ci/apple-host-assets.toml via the apple-host-asset tool, then use xcrun simctl to verify/install the runtime and create the named simulator device. This completes the chain: the TOML manifest pins the versions, the bootstrap ensures they're installed, and the compile lanes consume them.
Update Rust tests to match the new lane names
Intent: Keep the Rust test suite green by updating all assertions that reference the old `apple_host_sanity` lane ID to the new split lane IDs.
@@ -1193,7 +1193,7 @@
- let pikaci = fs::read_to_string(root.join("crates/pikaci/src/main.rs"))?;
+ let pikaci_targets = fs::read_to_string(root.join("crates/pikaci/src/targets.rs"))?;
Four test files are updated:
ci_manifest.rs and poller.rs — Assertions that the manifest contains apple_host_sanity are replaced with assertions for both apple_desktop_compile and apple_ios_compile.
forge_lanes.rs — The lane lookup test switches from finding apple_host_sanity to apple_desktop_compile, and adds an assertion verifying the apple-compile concurrency group. The nightly lane test is also updated to verify nightly_apple_host_bundle with its apple-runtime concurrency group.
guardrails.rs — The pikachat Apple split guardrail test now reads from targets.rs instead of main.rs, matching a prior refactor of where the pikachat_apple_followup_jobs function lives.
Update CI documentation to reflect the new two-lane contract
Intent: Keep the ci-selectors and integration-matrix docs accurate as the authoritative reference for what each CI lane owns.
@@ -28,7 +28,8 @@
-| `check-apple-host-sanity` | `just apple-host-sanity` on the Mac mini via `./scripts/pikaci-apple-remote.sh run --just-recipe apple-host-sanity`. This is the tiny blocking Mac smoke lane: `just desktop-ui-test` only. |
+| `check-apple-desktop-compile` | `just apple-host-desktop-compile` on the Mac mini via `./scripts/pikaci-apple-remote.sh run --just-recipe apple-host-desktop-compile`. This is the blocking desktop compile/link smoke lane and is intentionally compile-only via the Nix-addressable `appleDesktopCompile` package. |
+| `check-apple-ios-compile` | `just apple-host-ios-compile` on the Mac mini via `./scripts/pikaci-apple-remote.sh run --just-recipe apple-host-ios-compile`. This is the blocking iOS compile smoke lane
Both documentation files are comprehensively updated:
ci-selectors.md — The lane table replaces check-apple-host-sanity with the two new lanes, updates the nightly bundle description to mention desktop-ui-test as retained nightly coverage (not pre-merge), and corrects the deferred-mismatch section to reference the split compile lanes.
integration-matrix.md — The integration matrix gains rows for both compile lanes and demotes apple-host-sanity to an advisory/convenience wrapper. The "Non-Owner Entry Points" table is updated to show desktop-ui-test ownership moving from the pre-merge sanity lane to the nightly bundle. The Apple Silicon contract note and deferred asks section are reworded to describe the compile-only pre-merge contract.
Miscellaneous: .gitignore, xcode-run tool, and global __pycache__ pattern
Intent: Clean up ancillary files affected by the branch changes.
Affected files: .gitignore, tools/xcode-run
Evidence
@@ -77,6 +77,7 @@
+__pycache__/
A global __pycache__/ entry is added to .gitignore, complementing the existing directory-specific patterns. This prevents Python bytecode from the new inline Python dependency validator in apple-rust.nix (or any other Python tooling) from being accidentally committed.
The tools/xcode-run script is included in the affected files list as it's now referenced in the forge lane trigger paths for both compile lanes, ensuring changes to it correctly trigger the appropriate CI lanes.