Plan 0009
Cross-compiled Darwin packages from Linux
A package opts into macOS with one cross = true line; the Linux CI fleet cross-compiles it for Apple silicon, caches the closure, and a Mac substitutes the prebuilt binary instead of compiling anything.
- Status
- Load-bearing
- Ambition
- 7 darwin closures built entirely on the linux fleet
- Impact
- 8 macs stop compiling, one line opts a package in
- Effort
- 6 cross toolchains, cache aliasing, CI wiring
- Risk
- 5 cross-built artifacts can diverge from native darwin builds
- Maturity
- 6 landed and in daily use, native-parity gaps remain
- Leverage
- 8 every new package gets a mac binary for free
- Taste
- 5 the aliasing design took taste, the remaining parity work is mechanical
- Authors
- Created
- Updated
- Tracking issue
- -
- Supersedes
- -
- Superseded by
- -
Summary
A package opts into macOS with one line. The Linux CI fleet cross-compiles it for Apple silicon through the per-unit Rust build DAG, pushes the closures to cache.ix.dev, and aliases the results into the native Darwin package namespaces. A Mac then runs nix build .#<name> and substitutes a prebuilt binary instead of compiling anything. The package definition never learns any of this happened.
The contract
The entire package-side surface is one boolean in package.nix:
# packages/dag-runner/package.nix
{
id = "dag-runner";
packageSet = true;
flake = true;
overlay = false;
inRustWorkspace = true;
cross = true; # <- this line. That's it.
passthruTests = true;
} And the entire Mac-side experience is:
$ nix build github:indexable-inc/index#dag-runner # on an M-series Mac
copying path '/nix/store/…-dag-runner' from 'https://cache.ix.dev'…
$ file result/bin/dag-runner
Mach-O 64-bit executable arm64 No target triples in the definition, no cross ifs, no second default.nix, no Darwin builder anywhere. The tag declares intent; the build lane owns mechanism. Everything below is the mechanism.
Motivation
The repo’s primary target is x86_64-linux: the fleet, the CI runners, the image builds. But several repo-owned tools are exactly the things a developer wants locally on a Mac: nix-web-monitor, dag-runner, more to come. Two facts collide:
- CI is Linux. The warm-store runner pool that builds and caches every package is
x86_64-linux. There is no Darwin builder in the CI path. - Building from source on a laptop is the bad outcome. A cold
nix buildof a Rust tool with a Svelte front-end defeats the entire point ofcache.ix.dev. Linux users substitute; Darwin users were compiling.
The fix has to be declarative. The moment “make it work on a Mac” means hand-rolling a cross derivation per package, target logic leaks into definitions and every new tool pays the tax again. The toolchain half already exists in the sibling ix repo (nix/lib/apple-sdk-toolchain.nix); this plan ports it and reduces opting in to a registry tag.
Design principles
- Definitions are target-agnostic. A
default.nixconsumesix.rustWorkspace.unitsandix.wrapPackageand never branches on a triple. Cross is achieved by swapping what those handles mean, not by asking packages to handle it. - The target lives in the DAG, not the definition. The per-unit Rust build graph (Plan 0005) is already a DAG of derivations; cross-compilation is the same DAG re-rooted at a different triple.
- Darwin consumes, Linux produces. The Mac path is substitution from the cache, never local compilation. Cache availability is part of the design, not an optimization.
- One line in, one line out. Adding a package to the Mac set is adding
cross = true; removing it is deleting the line.
Detailed design
The tag
packages/registry.nix normalizes cross exactly like its sibling selectors (packageSet, flake, overlay): true expands to the default descriptor, an attrset overrides individual knobs, unknown keys are rejected at eval time:
cross = true;
# expands to
cross = {
attrName = id; # flake attr stem
exposeNativeDarwin = true; # alias into packages.<darwin-system>.<attrName>
systems = null; # build-host systems (null = all)
targets = [ "aarch64-apple-darwin" ];
}; The default target set is Apple silicon only: the fleet has no Intel-Mac users, and a second triple doubles the cross-build cost for artifacts nobody pulls. A package that needs x86_64-apple-darwin (or any other triple the lane supports) lists it in targets; the machinery below is triple-generic.
The registry exposes crossEntriesFor system, mirroring packageSetEntriesFor and flakeEntriesFor. Deliberately absent: backend, build, or wrap knobs. If a package needs those, the lane is wrong, not the tag.
The cross lane: swap ix, call the same default.nix
All cross building lives in lib/per-system.nix, gated on a Linux build host. For each Apple target it constructs a cross-configured ix and calls the package’s ordinary default.nix with it:
crossIxFor = target: ix // {
inherit pkgs;
cargoUnit = ix.cargoUnitFor pkgs;
rustWorkspace = crossWorkspace // {
units = crossWorkspace.unitsFor { inherit target; }; # the DAG, re-rooted
};
cross = { isCross = true; inherit target; targetSystem = targetSystemFor target; };
wrapPackage = wrapperPkgs: args: ix.wrapPackage wrapperPkgs (args // { isCross = true; });
};
buildCrossPackage = target: entry:
lib.callPackageWith (pkgs // { inherit entry repoPackages; ix = crossIxFor target; /* … */ })
entry.path { }; This is the whole trick. packages/nix/nix-web-monitor/server/default.nix calls ix.rustWorkspace.units and ix.wrapPackage identically for native and cross builds; the lane has quietly replaced units with the target unit graph and forced wrapPackage into cross mode (so nativePathSuffix host tools drop out of the wrapper). One definition, N targets.
The builder DAG
Plan 0005’s nix-cargo-unit renders the Cargo workspace into a graph of one derivation per compilation unit. unitsFor { target } renders that same graph for a non-host triple, and the result is a genuinely mixed-platform DAG: cargo already distinguishes host units from target units, and the renderer preserves the split.
build scripts, proc-macros ──run on──▶ x86_64-linux (build host)
│
▼ cfg flags, generated code
rlib units, final link ──emit──▶ Mach-O for aarch64-apple-darwin
▲
zig cc + macOS SDK sysroot (C deps, linker) - Host nodes stay host nodes. Build scripts and proc-macro crates compile for and execute on the Linux builder; only rlib and bin units target the Apple triple. Per-platform hooks (
extraRustcArgsForPlatform,extraLinkRustcArgsForPlatform) scope target-only flags (the framework search path, the SDK link args) to matching units, so host units are untouched by cross configuration. - The graph is pruned per target, not patched.
cargocfg-excludes platform-gated dependencies during rendering, so a Darwin graph simply never containsalsa-sys; there is no “skip this dep on Mac” conditional anywhere in Nix (lib/rust/workspace.nixgates ALSA plumbing ontargetIsLinux, derived from the triple). - Incremental like any DAG. Each unit is a cached derivation. Touch one crate and CI rebuilds that unit and its dependents in the Darwin graph, not the workspace: the same economics native builds already get from Plan 0005.
- Targets are data. The cross graph takes a toolchain carrying the target’s
rust-std(rustToolchainFor … { targets = [ target ]; }) and, for Apple triples only, the zig toolchain below. A musl oraarch64-linuxtriple needs neither zig nor an SDK: the sameunitsForcall with the ordinary linker.
The Linux→Darwin toolchain
Two pinned, overridable pieces, ported from the sibling ix repo:
lib/darwin/macos-sdk.nix: a public repackagedMacOSX15.4.sdk, pinned by SRI hash, unpacked to a store path. Sysroot for both the C compile and the Rust link. Overrideix.macosSdkto supply your own SDK for a stricter licensing posture.lib/darwin/apple-sdk-toolchain.nix: per-triple compiler/linker wrappers,zig cc/zig c++as the cross C/C++ compiler andclang -fuse-ld=lldas the Rust linker. Returns{ env, runtimeInputs, rustcArgsForPlatform };envcarries the per-target cargo variables (CARGO_TARGET_<T>_LINKER,CC_<t>,SDKROOT, a CMake toolchain file, …) that the unit renderer threads into each unit.
The wrappers go through the repo’s checked-bash writer (bash -n + shellcheck at build time) and absorb the toolchain’s sharp edges in one place: bridging Apple-vs-zig triple spelling (arm64-apple-macosx vs aarch64-macos), dropping flags zig rejects (-arch, -m64), stripping sanitizer flags, and pinning ZIG_GLOBAL_CACHE_DIR to a writable path so a sandboxed service user’s read-only HOME cannot cause a silent zig cc failure (ix ENG-1278).
Naming and Darwin aliasing
Cross outputs publish under the Linux system, keyed by triple, then alias into the native Darwin namespaces:
packages.x86_64-linux.dag-runner-aarch64-apple-darwin # the real derivation
packages.aarch64-darwin.dag-runner # alias -> same drv flake.nix does the merge in three lines:
linuxDarwinAliases = perSystem.x86_64-linux.darwinPackageAliases or { };
packages = rawPackages // lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ]
(system: rawPackages.${system} // (linuxDarwinAliases.${system} or { })); The merge covers both Darwin systems, but with the Apple-silicon-only default the x86_64-darwin alias set is empty until a package opts into that triple. exposeNativeDarwin = false keeps the triple-suffixed output but skips the alias, for a package that should be fetched explicitly rather than shadow a native name.
Substitution
The cache-push workflow realises .#cachePushRoots.x86_64-linux and pushes closures to the public ix-public atticd cache at cache.ix.dev on every merge to main. The triple-suffixed cross outputs are part of that roots set. Because the Darwin aliases are those derivations, a Mac’s nix build .#dag-runner resolves to a store path CI already pushed: substitution, not compilation. The cross unit graph is input-addressed (unlike the native workspace’s floating content-addressed default), so every cross output path is known at eval and the Mac substitutes it via a plain narinfo lookup — the cache serves no /realisations build trace, which a floating content-addressed output would need to map its derivation to an output path (#1755).
That push takes 15-25 minutes, so main briefly points at revs whose cross closure is not yet on the cache; a Mac that pins main inside that window hits the substitute-or-nothing wall (#1862). After the push succeeds, the workflow fast-forwards the cache-ready branch to the pushed commit. Consumers should pin github:indexable-inc/index/cache-ready instead of main: the ref trails main by one cache-push run, moves only forward along main history, and only ever points at revs whose darwin closure is already published.
Proved in CI, on every push
A green build does not prove the toolchain emitted a Darwin object; the smoke checks do. They run on the Linux CI host and read the Mach-O header:
cross-darwin-smoke = pkgs.runCommand "cross-darwin-smoke" { nativeBuildInputs = [ pkgs.file ]; } ''
bin=${crossPackages."dag-runner-aarch64-apple-darwin"}/bin/dag-runner
case "$(file -b "$bin")" in
*Mach-O*arm64*) ;;
*) echo "expected Mach-O arm64" >&2; exit 1 ;;
esac
mkdir -p "$out"
''; cross-darwin-web-monitor-smoke additionally asserts the wrapper is a portable #!/bin/sh script (a bash-from-the-Linux-store shebang would be dead on a Mac). A regression in the zig/SDK wiring fails a check on x86_64-linux CI rather than silently shipping a wrong-arch binary.
What does not need this lane: Haskell
GHC Linux→Darwin cross would be a category problem, but it turns out nothing here needs it. nix-output-monitor, the one Haskell tool in the tree, is a nixpkgs expression (pkgs/by-name/ni/nix-output-monitor) with a single leaf patch on haskellPackages.nix-derivation (packages/nix/nix-output-monitor/default.nix). Unlike the Rust workspace, that path has no Linux-only IFD: on a Mac it evaluates natively, GHC and the unpatched dependency closure substitute from cache.nixos.org, and only the patched leaf and nom itself compile locally. Haskell tools ride nixpkgs’ native Darwin path and never enter the cross lane; the lane’s scope is repo-owned Rust (plus its C/C++ and Svelte inputs), which is exactly what the unit DAG covers.
Migration
Adding a package to the Mac set: add cross = true to its package.nix. Removing it: delete the line. Nothing else changes, because the definition was already target-agnostic (or becomes so by using ix.rustWorkspace and ix.wrapPackage instead of reaching for pkgs directly). The initial set is dag-runner and nix-web-monitor.
Drawbacks
- The Mac path is substitute-or-nothing. Evaluating a cross output on a Mac forces the Rust unit-graph IFD, which is an
x86_64-linuxderivation and fails with a platform mismatch. If the cache has the path, the Mac user gets it; if the cache is cold, there is no slow-but-working local fallback. Cache availability is load-bearing. - Trust and provenance. The artifacts are Mach-O binaries built on Linux with a repackaged SDK, unsigned and un-notarized. Gatekeeper friction and “who built this Mac binary” are real and unaddressed here.
- SDK licensing. Apple licenses the macOS SDK for use on Apple hardware. The default fetches a public repackaged tarball (posture inherited from the sibling
ixrepo); theix.macosSdkoverride exists for consumers who want a stricter stance. - Untested on target. The smoke checks prove arch and wrapper shape, not runtime behavior. A cross build can link clean and still fail at a dlopen or framework version on a real Mac. Confidence comes from developers running the tools, not from a gate.
Alternatives
- Do nothing; Mac users build from source. Always correct, always slow, cache-defeating for exactly the tools people want locally. Rejected as the default experience.
- Native Darwin CI builders. The most robust answer (real toolchain, real ABI, on-target tests) and the obvious future direction. Rejected for now on cost and operational surface; this lane is the bridge, not the competitor.
- Stock nixpkgs
pkgsCross. Drags in the same SDK-licensing question, needs per-package cross plumbing, and does not compose with thenix-cargo-unitper-unit graph as cleanly as threading a toolchainenvthrough the existing renderer. The zig toolchain is already proven in the sibling repo. - osxcross / hand-built cctools + ld64. The traditional route; more moving parts and more brittle than
zig cc+lld, which ship a maintained cross compiler and linker with SDK-sysroot support in one binary. - Per-package bespoke cross derivations. The thing this design exists to prevent: target logic scattered across definitions, and every new package re-paying the plumbing cost the tag amortizes to one line.
Prior art
- Plan 0005 (cargo-drv). The per-unit workspace DAG whose
unitsFor { target }hook this lane calls. Cross is a consumer of that renderer’s per-platform hooks, not a new build system. - The registry’s target selectors.
packageSet/flake/overlaynormalization inpackages/registry.nixestablished the “tag inpackage.nix, collect in the registry, realize inper-system.nix” shape thatcrossfollows exactly. - The sibling
ixrepo’snix/lib/apple-sdk-toolchain.nix. Direct source of the toolchain; same SDK tarball and hash, so the SDK store path is shared between repos. zig ccas a cross compiler. A full clang + sysroot cross toolchain behind one binary; an established pattern well beyond this repo.- nixpkgs
pkgsCross. The general build-platform/host-platform model and theCARGO_TARGET_<T>_*conventions this lane sets.
Unresolved questions
- Guaranteeing a cache hit before a Mac pulls. A merged-but-not-yet-pushed package is broken for Mac clients until
cache-pushcompletes. Gate the Darwin alias on a cache-presence check, or accept “merge then wait for the push job”? - Curation. The tag makes opting in trivial, which makes over-tagging easy. Is the criterion “a developer runs this locally on a Mac”, and who enforces it?
- On-target signal. Without Darwin CI, is a lightweight periodic “does it launch” check worth formalizing, or do we accept build-only confidence until native runners exist?
- Signing / notarization. Do the binaries need codesigning to be pleasant under Gatekeeper, and where in the pipeline would that live?
Future possibilities
- Cross to
aarch64-linuxand musl.unitsForalready handles non-Apple triples with the ordinary linker; the tag’stargetslist can grow beyond Apple silicon with no new machinery. - Native Darwin CI runners. Build and test natively, keep this lane as the fast path or retire it, and push even the locally-compiled Haskell leaves into
cache.ix.dev. - Codesigning and notarization. A post-build signing step, credentialed the way the cache-push token is, would make the artifacts first-class on locked-down Macs.
- On-target verification. A single Mac runner doing “substitute the pushed closure, launch it” converts build-only confidence into runtime confidence without a native build pipeline.
- Shared cross artifacts across repos. The SDK store path is already shared with
ix; identical toolchain inputs could let the repos substitute each other’s cross dependency closures.
andrewgazelka