Ch 2 ended on a warning: the review funnel is only as trustworthy as the checks feeding it. This chapter builds those checks — and it has to build them for five very different targets at once. A Cloudflare Worker, an iOS app, an Android app, a Mac app, and a Windows app share almost no build tooling. The naive response is five separate, copy-pasted pipelines that drift apart within a month. The good response is one pipeline with a shared spine and five short platform-specific tips.
By the end you'll know how to wire that: the fail-fast DAG that runs cheap shared checks before expensive platform builds, how jobs find the right runner, what to share and what to keep separate, and the caching that decides whether your pipeline takes ninety seconds or ten minutes. We'll keep grounding it in this repo, which already runs the shared half (npm run check) and the hardest platform half (a notarized Mac build).
The Two Halves of Every CI Run
Here's the structural insight that keeps cross-platform CI sane. Every CI run splits into two halves:
- The shared half — checks that are identical regardless of target: does the code lint, type-check, pass unit tests, satisfy the project's guard rules? This half is cheap, fast, and runs on the cheapest Linux runner there is.
- The platform half — the build/sign/package work that's different for every target (the six-stage spine from Ch 1). This half is expensive, slow, and platform-bound.
The entire art of cross-platform CI is: run the shared half once, fast and cheap, and only pay for the platform half if the shared half passed. Don't spin up a 10x-billed macOS runner to build an iOS app whose code doesn't even lint. Fail on the cheap machine first.
Figure 1 — The fail-fast fan-out. One cheap shared gate runs first; the five expensive platform builds fan out in parallel only if it's green, and run on different runners. This is the difference between a pipeline that costs cents and one that burns your whole macOS quota on code that didn't compile.
That "only if green" arrow is a job dependency — in GitHub Actions it's the needs: keyword. It's the single most important line in a cross-platform pipeline.
Building the DAG
A pipeline isn't a list of steps; it's a directed graph of jobs, where edges are needs: dependencies. Here's the shape of Figure 1 as a real (abbreviated) workflow:
jobs:
# ── the shared half: one cheap gate ──
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm } # ← dependency cache, free speed
- run: npm ci
- run: npm run check # the same gate from web Ch 25
# ── the platform half: fan out, each gated on `check` ──
web:
needs: check # ← won't start unless check is green
runs-on: ubuntu-latest
steps: [ /* build + deploy the Worker */ ]
ios:
needs: check
runs-on: [self-hosted, macos] # ← the Mac mini
steps: [ /* xcodebuild + sign + upload */ ]
android:
needs: check
runs-on: ubuntu-latest # Android builds on Linux — no Apple tax
steps: [ /* gradle bundleRelease + sign */ ]
mac:
needs: check
runs-on: [self-hosted, macos]
steps: [ /* xcodebuild + notarize + staple */ ]
windows:
needs: check
runs-on: windows-latest
steps: [ /* msbuild + signtool */ ]Three properties make this good, not just functional:
- Fail-fast. If
checkis red, none of the five platform jobs ever start. You spend ~2 cheap Linux minutes to find out the code is broken, instead of five parallel expensive builds. - Parallel. The five platform jobs have no dependency on each other, only on
check— so they run simultaneously. Wall-clock time is "shared check + slowest single platform," not the sum of all six. - One definition of "the shared gate." Every platform job sits behind the same
checkjob. There's no per-platform copy of the lint config to drift.
Runner Routing: The Right Machine for Each Job
The runs-on: line is how each job finds its machine. Getting it right is most of what keeps the bill near zero — it's the Ch 1 fleet model expressed in YAML.
| Job | runs-on: | Where it runs | Why |
|---|---|---|---|
| Shared checks | ubuntu-latest | Cloud Linux | Cheapest, fastest, free tier is huge. |
| Web build/deploy | ubuntu-latest | Cloud Linux | OpenNext/wrangler are Linux-fine. |
| Android | ubuntu-latest | Cloud Linux | No Apple dependency; 1x rate. |
| iOS / Mac | [self-hosted, macos] | Your Mac mini | Apple-only build; dodges the 10x macOS tax (web Ch 24) and reuses the mini's signing identity. |
| Windows | windows-latest | Cloud Windows | Authenticode signing + MSIX tooling are Windows-bound; 2x rate, but runs rarely. |
The labels do the matching. ubuntu-latest / windows-latest are GitHub's hosted pools; [self-hosted, macos] matches only a runner you registered with both labels — your mini. A job requiring [self-hosted, macos] will queue until the mini is free, then run there and nowhere else. (That queuing is the one cost of a single self-hosted box; Ch 4 covers adding a second runner or bursting to cloud when the mini is the bottleneck.)
Shared vs Platform-Specific: Draw the Line Once
The maintainability of a cross-platform pipeline comes down to one decision made well: what lives in the shared job, and what lives per-platform. Get the line right and adding a sixth target is a few lines. Get it wrong and every change means editing five files.
Figure 2 — The line. Everything platform-agnostic (the green block) lives in the shared check job and runs once. Each platform job is just the four blue stages from the Ch 1 release spine. The blue tips are small because the green trunk did the heavy lifting.
The rule of thumb: if a step would produce the same pass/fail result on any OS, it belongs in the shared job. Linting, type-checking, and unit tests almost always do. Anything that touches a compiler toolchain, a signing identity, or a packaging format is platform-specific by definition.
This is also where the "one check script" principle from web Ch 25 pays off across platforms: the shared job runs npm run check, the same command a developer runs locally on a MacBook Pro. The web and saas halves of this repo each define that one script, so the shared CI job is a one-liner that can never drift from local.
Caching: The Difference Between 90 Seconds and 10 Minutes
A cold CI job re-downloads every dependency from scratch — npm ci pulling hundreds of packages, Gradle fetching the Android toolchain, SPM resolving every Swift package, NuGet restoring the world. Caching makes the second run reuse the first run's downloads, and it's the single biggest speed lever you have.
| Platform | What to cache | How |
|---|---|---|
| Node (web/saas) | ~/.npm / node_modules | actions/setup-node with cache: npm (built in) |
| iOS / Mac | SPM packages, DerivedData | actions/cache keyed on Package.resolved |
| Android | Gradle caches, wrapper | actions/setup-java with cache: gradle |
| Windows | NuGet packages | actions/cache keyed on packages.lock.json |
Two rules keep caches correct instead of cursed:
- Key the cache on the lockfile. The cache key should include a hash of
package-lock.json/Package.resolved/ the Gradle lockfile. When dependencies change, the key changes, and you get a fresh cache — never a stale one. - The self-hosted runner caches for free, persistently. The mini doesn't reset between runs like a hosted VM, so its DerivedData and SPM caches survive — the second iOS build on the mini is dramatically faster than the first with no cache config at all. That persistence is a real advantage of owning the box, not just a cost dodge.
Build Once, Deploy Many — With Artifacts
The Ch 1 principle "build once, deploy many" has a concrete CI mechanism: artifacts. A build job produces a file (a .ipa, an .aab, a notarized .dmg, an .msix); it uploads that file as an artifact; later jobs (or a human approving a release) download the exact same bytes and ship them.
Figure 3 — Build once, promote the same bytes. The artifact built and tested in staging is byte-for-byte what reaches production — never a fresh rebuild that might differ. This is what makes "it passed in staging" actually mean something. Ch 5 (web) and Ch 6 (apps) use this for promotion and rollback.
The anti-pattern this kills: rebuilding for each environment. If staging builds from commit abc and production rebuilds from the same commit, a dependency that floated, a toolchain that updated, or a timestamp baked into the binary can make them differ — and now "tested in staging" is a lie. Build the artifact once; carry it forward unchanged.
The Matrix: One Job, Many Combinations
There's a second, different use of "many targets" worth knowing: the matrix strategy, for when you want to run the same job across several variations — say, testing your web code on Node 20 and 22, or your library on Linux, macOS, and Windows.
test:
strategy:
fail-fast: true
matrix:
node: [20, 22]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}", cache: npm }
- run: npm ci && npm testThat's four parallel jobs from one definition (2 Node versions × 2 OSes). Two things to know:
fail-fast: truecancels the other combinations the moment one fails — you usually want this in CI (why keep burning minutes once you know it's broken?). Set itfalseonly when you specifically want to see which combinations fail.- Don't confuse the matrix with the fan-out. The matrix runs the same job across variants (test on many Node versions). The fan-out from Figure 1 runs different jobs (build iOS vs build Windows). A real pipeline uses both: a matrix for cross-version testing inside the shared half, and a fan-out for the platform builds.
What This Project Actually Does
To stay honest, same as every chapter: this repo runs the shared half of this architecture today, and the seed of the platform half.
- Shared:
website-nextandsaaseach runnpm run check(lint, typecheck, and the project's guard scripts —check:content-coverage,check:stripe-links, and the rest). That's the green trunk of Figure 2, and it runs locally before every deploy. - Platform: the one wired CI job,
macos-build.yml, compiles the Mac app on the[self-hosted, macos]mini — the blue tip for the Mac column. It's currently a constrained compile-check (no signing/secrets), but it's the exact routing-and-needsshape this chapter describes. - The gap to the target: promoting that single job into the full Figure 1 fan-out —
checkas the shared gate, then web-deploy and the signed-and-notarized Mac build as parallel jobs gated on it, with artifacts carried forward. This repo only ships two of the five targets, so its real fan-out is two-wide, not five — but the shape is identical.
The honest summary: the fail-fast, one-shared-gate, route-by-label architecture is exactly what this project's pieces already imply — wiring them into one DAG is the next step.
Mental Model — Three Sentences
- Every CI run is a cheap shared half (lint/typecheck/test/guards, identical on any OS) and an expensive platform half (build/sign/package, different per target) — so run the shared half once on cheap Linux and only fan out to the expensive platform builds if it's green.
- Route each job to the cheapest machine that can run it: Linux in the cloud for web, Android, and shared checks; your Mac mini for the Apple targets (dodging the 10x tax); a Windows runner for Windows — the
runs-on:label is your whole cost-control strategy. - Keep the line between shared and platform-specific drawn once, cache aggressively keyed on lockfiles, and build each artifact once and promote the same bytes — and a five-target pipeline stays one maintainable graph instead of five drifting copies.
Try It Yourself (15 Minutes)
- Find your shared half. In any project, list the checks that would pass or fail the same on every OS. That set is your shared
checkjob — the rest is platform-specific. - Add one
needs:. Take a workflow with independent jobs and gate the expensive one on the cheap one (needs: check). You just made it fail-fast — the expensive job stops running on broken code. - Turn on a dependency cache. Add
cache: npm(orgradle/nuget) to your setup step and compare a cold run to a warm one. The speedup is usually dramatic and free. - Audit your
runs-on:lines. Is anything building on a macOS runner that could build on Linux (hello, Android)? Each one you move toubuntu-latestis a 10x saving.
Where This Lands in the Series
You now have the engine room: a fail-fast DAG, runners chosen by cost, a clean shared/platform line, caching, and immutable artifacts — the checks that make the Ch 2 review funnel trustworthy across every platform at once.
This chapter kept routing the heavy jobs to "the Mac mini" as if it were a solved thing. It isn't yet. Ch 4 opens the box: how to actually architect that mini-plus-MacBook-Pro cluster — registering runners and labels, hardening the one trusted machine, Tailscale networking so jobs reach it safely, keychain and secret isolation, and what to do when the single mini becomes the bottleneck. The pipeline has been assuming the fleet; next we build it.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper