If you are on call with two or more rented cloud Macs running Fastlane for iOS releases, and you recently saw green compile logs but no new TestFlight build (or the wrong package uploaded), this article is for you. Still deciding whether to move to the cloud or add a second machine? Read the TestFlight dedicated runner or moving Xcode builds to the cloud first — here we assume you already split one machine to build, one to upload.
After reading, you should leave with two things: ① which class of problem the logs point to — certificates, artifacts, or network; and ② whether your two machines have the right roles, plus whether buying more hardware would actually help.
- Build machine: compiles the project in Xcode and exports an installable package (
.ipa) - Upload machine: signs with the release certificate and uploads the
.ipato TestFlight; the egress IP registered in App Store Connect should match this host only - Archive / “the build”: Xcode’s distributable output; between machines we hand off only the finished
.ipa, not the whole project cache folder - match: Fastlane tool that manages release certificates and provisioning profiles — run on one machine only
You can skip this if: you only have one Mac, or TestFlight uploads have been fine and you have not split into separate build and upload hosts. If you already use multiple cloud Macs for release and the pipeline is failing, keep reading.
- Certificate boundary — the release certificate was synced to the build machine, so the upload machine cannot sign (most dangerous)
- Artifact boundary — moving
.xcarchivebetween datacenters instead of a verifiable.ipa(easiest to miss) - Network identity drift — App Store Connect still whitelists an old upload IPv4 (hardest to debug)
Context (1 minute): With two Macs, boundaries break more often than CPUs
Many teams rent two cloud Macs: one closer to Asia to compile the app into an install package during the day, and one in Canada to deliver the package to Apple (TestFlight / App Store). The second machine is often about speed — but on-call time usually goes to both machines touching certificates and files: the build host shows success while TestFlight never gets a new version.
Below we first describe the common wrong split (Model B), then the split we use (Model A) and how to verify it.
Recommended split: Model A (build only packages, upload only publishes)
In one line: the build machine only produces an .ipa; the upload machine owns certificates and TestFlight. Do not run certificate sync (match) on both hosts — the private key lands on the wrong machine and the upload host reports a missing distribution certificate.
| Role | Build machine (Hong Kong) Example: daytime builds in APAC | Upload machine (Canada) Fixed IP for Apple |
|---|---|---|
| Do | Pull code → Xcode build → export .ipa | Receive .ipa → release signing → upload to TestFlight |
| Do not | No match, no TestFlight upload | Do not run a full project compile |
| Hand off to the other host | Install package file + build ID (RUN_ID) | None (endpoint is App Store Connect) |
For engineers mapping to Fastlane: the build host’s lanes only compile and export; the upload host has match, upload_to_testflight, and the App Store Connect API key. Register only the upload host’s egress IP in Apple’s backend.
Both runners sit on dedicated Hashvps Mac minis — mainly for fixed IP + role isolation (upload IP in ASC, build IP not). Hong Kong specs: Hong Kong Mac mini plans. Lock upload identity in ASC before you horizontally scale build runners.
Common mistake: Model B (both machines “helping”)
Model B means roles were never separated — we ran it this way in week one. It looks like two machines sharing work, but in practice:
- Both hosts run
match→ the release private key stays on the build machine → the upload machine cannot sign → TestFlight keeps failing - Copying the whole
.xcarchivefolder to the other host to export again → mismatched configs → Apple rejects the package - Each host exports its own
.ipa→ different export options → logs say success, TestFlight has no usable build
If any line matches your pipeline, a third Mac usually will not help — clarify who builds, who uploads, and who owns certificates using Model A first.
Observability: prove build and upload are the same run
The most common ops mistake is uploading last night’s IPA. We print the same fields in build and upload logs:
RUN_ID(pipeline run, e.g.hk-build-88421)GIT_SHA(short commit hash)- Artifact path + SHA256 (written to
build/manifest.json)
# Hong Kong · build [15:11:42]: Successfully exported: ./build/MyApp-20260604-1502.ipa [15:11:42]: RUN_ID=hk-build-88421 GIT_SHA=a3f91c2 SHA256=9f2a… # Canada · upload [02:22:01]: RUN_ID=hk-build-88421 GIT_SHA=a3f91c2 ipa_sha256=9f2a… [02:24:18]: Successfully uploaded package to App Store Connect
On-call rule: if the upload log doesn’t show the same RUN_ID, treat it as an artifact mismatch first — not ASC or Apple status pages.
30-second triage: no new TestFlight build?
Don’t scroll the full log yet. One level in the tree is enough to land on the right section (bookmark this for your runbook):
No new TestFlight build?
├─ Upload RUN_ID ≠ build RUN_ID (or missing manifest)
│ → Pitfall 2 · artifact boundary
├─ Logs mention match / AppStoreDistribution / code signing identity
│ → Pitfall 1 · certificate boundary
└─ Upload “success” but ASC empty / 403 / Invalid Binary (build all green)
→ Pitfall 3 · network identity drift
| Pattern | Log keywords (grep) | Release impact |
|---|---|---|
| Pitfall 1 · certificate | AppStoreDistribution, No matching provisioning, match failed |
Blocker: TestFlight / store upload stopped |
| Pitfall 2 · artifact | RUN_ID mismatch, Invalid Binary, export_method conflict |
High: wrong build or can’t promote |
| Pitfall 3 · identity | 403, upload OK with no processing, ASC network limits |
Intermittent: feels random |
Three failure modes (expanded)
Pitfall 1: certificate boundary wrong
30 seconds: build archive green; upload red, failing at match / signing.
Log: AppStoreDistribution, Could not find a matching code signing identity, No matching provisioning profiles.
Release: Blocker — TestFlight and App Store uploads stay down until the private key lives only on the upload host.
Symptom: archive succeeds on the build machine; upload lane fails; TestFlight shows no new build.
Could not find a matching code signing identity for type 'AppStoreDistribution' ❌ Lane upload failed
Root cause: Model B — the build machine still runs match(appstore); the distribution key is not on the upload machine.
Fix: remove all match from the build machine; upload owns it exclusively (rotate only there, in its own window). See match readonly.
Pitfall 2: artifact boundary wrong
30 seconds: compare RUN_ID and ipa_sha256 in build vs. upload logs; or ASC reports Invalid Binary with no signing error on build.
Log: RUN_ID mismatch, Invalid Binary, double export_ipa / ExportOptions on two hosts.
Release: High — old commit, wrong build, or stuck processing; pipeline may still look green.
Root cause: moving .xcarchive between datacenters or exporting twice. Smallest cross-DC unit: IPA with SHA, not an archive folder.
Fix: one export_ipa + manifest.json on build; upload only uploads, no second export.
Pitfall 3: network identity drift
30 seconds: Fastlane reports upload success; signing green on both hosts; TestFlight empty or ASC 403.
Log: 403, Successfully uploaded with no new build, ASC network rejection despite green build logs.
Release: Intermittent — works sometimes, doesn’t others; eats on-call time.
Root cause: upload IPv4 ≠ entry in the ASC whitelist (third, quiet failure class).
Fix: daily curl -4 ifconfig.me on upload vs. ASC; on drift, update whitelist + alert. Build IPv4 should not be in ASC — a wrong entry there masks real upload drift.
How to know the split actually worked
One checklist instead of scattered FAQ — one week after rollout, every row should be an unambiguous yes:
| Check | Expected |
|---|---|
| RUN_ID aligned | Build export and upload logs match on RUN_ID, GIT_SHA, ipa_sha256 |
| Build has no Distribution | Build host keychain/logs never show AppStoreDistribution or a match step |
| Upload is upload-only | Upload lane: only match (or resign) + upload_to_testflight, no ARCHIVE SUCCEEDED |
| Artifact shape | Cross-DC handoff is .ipa + manifest (< 200 MB); no .xcarchive rsync |
| ASC identity | Current upload IPv4 = ASC whitelist; build IP not in ASC |
| Runner isolation | Labels role=ios-build-hk and role=ios-upload-ca, workflow needs chain |
Where are you on the maturity curve?
Incident fixes answer “tonight.” Stage answers “change architecture next week?” Four stages — hold, correct, or force role split.
Stage 1: one Mac, one Fastlane
Signals: one runner; match, build, upload in one Fastfile and keychain. Tune disk, Derived Data, and GitHub Actions billing before adding a second build node.
Verdict: no dual-Mac needed. APAC builds slow only → add a build node, don’t split upload first.
Stage 2: two Macs, no role split (Model B)
Signals: two or more runners; multiple run match / export / upload; logs show the three boundary failures in this article.
Verdict: ❌ A third Mac fixes nothing; it raises cert and artifact drift. Next step: Model A, not more runners.
Stage 3: role split (Model A · target of this post)
Signals: build/upload separated; cross-DC handoff is IPA with RUN_ID; match only on upload; fixed upload IP.
Verdict: ✔ acceptance table stays green → scale build runners horizontally or harden the upload host. Fixed IPs and per-node ordering: Hashvps plans (lock ASC IP for upload first, then expand build).
Stage 4: multiple upload regions (advanced)
Signals: upload in US/EU/APAC; ASC keys and IP policy per region; promotion and compliance as their own line.
Verdict: you’re in distribution platform territory, not “one more CI Mac” — artifact registry, policy, audit; outside this runbook.
| Stage | Do this now |
|---|---|
| Stage 1 | Stay on one Mac; tune build/upload windows |
| Stage 2 | Stop buying Macs; implement Model A + acceptance table |
| Stage 3 | Scale build horizontally; tighten upload monitoring |
| Stage 4 | Separate project: multi-region distribution, not Fastlane patches |
Final verdict (this block is enough to act)
If all three are true, decide here — no need to reread the rest:
- At least 2 Mac runners (or equivalent multi-node CI)
- Fastlane chain with unstable TestFlight (
match failed, processing stuck, sporadic missing builds) matchhas run on more than one host
👉 Don’t buy more hardware.
👉 Three steps (start today):
- Stop
match/sync_code_signingon every non-upload node (signing stays on upload only) - Enforce cross-DC handoff of
.ipa+manifest.jsononly (no.xcarchiversync) - Standardize
RUN_ID+GIT_SHA+ipa_sha256; upload must verify before upload
If you can’t do those three: you’re still in Stage 2 (Model B). More hardware amplifies the failure; it doesn’t fix it. Fix Fastfile and workflow labels first, get the acceptance table green, then talk build capacity.
lane :upload_only do |options| verify_run_id!(options) # manifest.json match(type: "appstore", readonly: true) upload_to_testflight(ipa: options[:ipa_path], api_key_path: "asc_api_key.json") end
Quick FAQ
Can the build machine archive without Distribution signing? Project-dependent; we export on build an IPA for final App Store signing on upload (or build is compile-only and upload resigns). Non-negotiable: match on one host only.
match expiring soon? Rotate on upload only in a non-readonly window; pause build until profiles stabilize.
How is this different from the travel runbook? Travel runbook = Wi‑Fi on the road; this one = datacenter roles and Fastlane boundaries — copy the acceptance table as-is.
Series: Part ① Runbook: dual-cloud Mac + Fastlane triage (empty TestFlight / match failed / multi-Mac iOS CI). Coming: ② Build·Sign·Distribute architecture, ③ Model B deep dive; setup entry point TestFlight dedicated runner.