Fly4cast was done on web and Android. iOS was needed, and I had no Mac.
“Buy a Mac” is the easy answer. But buying hardware to test an app is purchasing, not solving. I tried the other way: write the code on Windows, build in the cloud.
First obstacle: the project file
An Xcode project is not a single file; it is a large structure recording every source file and every setting. It cannot be edited by hand, merged, or produced on Windows.
The fix: do not keep the project file at all. You write a definition file, and the real project is generated at build time.
ios/project.yml ← the only thing in git, readable
↓ (on the build server)
ios/App.xcodeproj ← generated, never committed
Side benefit: merge conflicts in project files cease to exist.
The real obstacle: signing
The build settled in a few attempts. Signing took three days — because it is not one operation but four interlocking things:
| piece | what it is | when it breaks |
|---|---|---|
| Certificate | the key pair identifying you | “no valid signing identity” |
| Profile | which app, which devices, which entitlements | archive builds, upload rejected |
| Entitlements | push, app groups, background modes | the feature silently does nothing |
| API key | the identity performing the upload | build finishes, nothing uploads |
Trap 1 — the wrong profile type
The first failure was the most instructive: archiving died with “no team found / no registered devices”.
The cause: the environment had fetched a development profile. Development profiles produce builds that run only on registered devices; a cloud runner has none, so no archive is produced. The store needs a distribution profile.
Trap 2 — how the key is written to disk
The upload key is a PEM file: it looks like text but its line breaks are meaningful. Reading it from an environment variable and writing it out left the newlines as escape sequences.
echo "$KEY" > key.p8 # \n escapes written literally
printf %b "$KEY" > key.p8 # escapes converted to real newlines
A one-word difference, half a day.
Trap 3 — where the output is
Build succeeded, signing succeeded, upload said “no file found”. The path the archive was exported to was not the path the uploader was looking in. The pattern in the configuration pointed one level up.
These are the most maddening failures: everything works, the two sides are just looking in different folders.
The build number
At first I incremented by a fixed offset. It went wrong quickly: some builds did not upload, numbers advanced with gaps, and which version was where became unclear.
The correct way is simple: before uploading, query the highest published number and add one.
And a bug that did not exist
At one point I spent two hours on an analytics library that “would not build”. It would not build because I had not pushed the code to the repository the build server pulls from. It existed locally and not remotely.
When something “works locally but not in CI”, first verify what CI actually sees. In most cases there is no difference in the code; CI is looking at something you did not push.
In the end
The flow now: write on Windows, push, a cloud macOS runner generates the project, builds it, signs it with the distribution profile and uploads to TestFlight. A notification arrives on my phone.
Total setup: three days. A Mac costs more than that — and more importantly, this setup works the same way for every build; it does not depend on the state of my machine.
The real gain is not saved hardware: it is a reproducible build environment. That is what removes the sentence “it works on mine”.