Tutorials Modern Delivery Pipeline Chapter 3

One Pipeline, Five Targets: Cross-Platform CI That Stays Fast and Cheap

DeliveryChapter 3 of the Modern Delivery Pipeline26 minJune 7, 2026Intermediate

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:

  1. 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.
  2. 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.

Loading diagram…

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:

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.

Jobruns-on:Where it runsWhy
Shared checksubuntu-latestCloud LinuxCheapest, fastest, free tier is huge.
Web build/deployubuntu-latestCloud LinuxOpenNext/wrangler are Linux-fine.
Androidubuntu-latestCloud LinuxNo Apple dependency; 1x rate.
iOS / Mac[self-hosted, macos]Your Mac miniApple-only build; dodges the 10x macOS tax (web Ch 24) and reuses the mini's signing identity.
Windowswindows-latestCloud WindowsAuthenticode 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.

Loading diagram…

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.

PlatformWhat to cacheHow
Node (web/saas)~/.npm / node_modulesactions/setup-node with cache: npm (built in)
iOS / MacSPM packages, DerivedDataactions/cache keyed on Package.resolved
AndroidGradle caches, wrapperactions/setup-java with cache: gradle
WindowsNuGet packagesactions/cache keyed on packages.lock.json

Two rules keep caches correct instead of cursed:

  1. 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.
  2. 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.

Loading diagram…

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 test

That's four parallel jobs from one definition (2 Node versions × 2 OSes). Two things to know:

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.

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

  1. 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.
  2. 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.
  3. 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)

  1. 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 check job — the rest is platform-specific.
  2. 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.
  3. Turn on a dependency cache. Add cache: npm (or gradle/nuget) to your setup step and compare a cold run to a warm one. The speedup is usually dramatic and free.
  4. Audit your runs-on: lines. Is anything building on a macOS runner that could build on Linux (hello, Android)? Each one you move to ubuntu-latest is 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.

Ch 2: The Agent-Assisted Pull RequestCh 4: Your Own Build Cluster — The Mac mini + MacBook Pro Fleet
Git + GitHubGit & GitHub Pro SeriesGit and GitHub practices for branches, pull requests, rebase, history repair, and team review.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.

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
5 free articles remainingSubscribe for unlimited access