Shipping an iOS app without a Mac: GitHub Actions as the build machine
An iOS app needs Xcode, and Xcode needs macOS. I work on a Linux virtual machine in the cloud, and I have no Mac with Apple silicon. Two of my apps are nevertheless in TestFlight now, built, tested, signed and uploaded entirely by GitHub Actions. This is how the setup works and where it bites. (How the apps share their code with Android is in the previous article.)
The options
- Buy a Mac. The obvious answer, and the right one for daily iOS work. For two apps whose code is shared Kotlin, a machine that would mostly build was hard to justify.
- A macOS virtual machine on my Linux server. Apple's licence allows macOS only on Apple hardware, and the cloud machine has no GPU anyway.
- A rented Mac in the cloud. Possible, but my cloud provider has none, and it is one more machine to maintain.
- GitHub's hosted macOS runners. A fresh Mac with the current Xcode for every run, paid per minute, nothing to maintain.
I chose the runners and decided on one rule: everything Apple-only runs in CI, and a script that CI runs works the same on a real Mac if I ever have one.
What the Linux machine can do
More than I thought. Kotlin/Native compiles the iOS targets of the shared code on Linux; it cannot link an iOS framework, but compilation is where most mistakes show up. So the coding agents on the Linux machine check every change with:
./gradlew :shared:compileKotlinIosSimulatorArm64
plus the shared tests on the JVM. Swift code, the Xcode project, the simulator and anything about signing need macOS and go to CI.
The iOS workflow
One workflow runs on every change to shared code or to the iOS host, and on pull requests. It calls one shell script that:
- Picks a simulator by query, not by name. Device names change with every Xcode release, so the script asks
simctlfor the newest iOS runtime and the first iPhone on it. - Runs the shared Kotlin tests on that simulator, and links the framework for a real device too: a link error that only happens for devices should fail here, not in the first TestFlight build.
- Generates the Xcode project with XcodeGen from one YAML file. The
.xcodeprojis not in git, so there are no merge conflicts in a file nobody can read. - Runs the app's unit and UI tests with
xcodebuild test, and the small-screen checks again on the smallest iPhone the simulator still offers. - Films every run and exports the screenshots from the result bundle, and on a crash prints the simulator's crash reports into the log.
The filming part is short:
xcrun simctl boot "$UDID" || true
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "media/$NAME.mp4" &
RECORDER=$!
xcodebuild test -project App.xcodeproj -scheme App \
-destination "id=$UDID" -resultBundlePath "results/$NAME.xcresult" CODE_SIGNING_ALLOWED=NO
kill -INT "$RECORDER"
xcrun xcresulttool export attachments --path "results/$NAME.xcresult" --output-path "media/$NAME"
The videos and screenshots go up as an artifact that expires after a day or a week. Without a device on my desk, they are how I see the app: a failed UI test comes with a recording of what the screen actually did.
Numbers
While the grammar trainer was moving to iOS, the iOS workflow ran 55 times: 29 green, 14 red, 12 cancelled because a newer push replaced them. A green run takes about 20 minutes (from 8 to 33). macOS minutes are the most expensive kind GitHub sells, and in a private repository each of them counts as ten minutes of the plan, so the workflow only starts when files that can affect iOS change, and a new push cancels the run in progress.
What the runners taught me
Most red runs were real bugs. Some were lessons about the environment:
- A hidden web view stole the taps. On a fresh simulator, Google's consent SDK keeps an invisible full-screen web view, and XCUITest called the bottom cells of a Compose list "not hittable". Dragging by coordinates instead of tapping elements fixed the tests; the app itself was fine.
- StoreKit's test session loaded the configuration and returned no products. It happened on the runners with every file format I tried. Those tests now skip themselves in that case and say why; the purchase gets checked on a real device through TestFlight.
- Notifications are never allowed on CI. The simulator refuses notification requests without permission, so the tests check the reminder requests the app builds, not their delivery.
- The upload can fail on Apple's side. One TestFlight upload ended in a 504 from Apple; the same run started again went through.
Signing without Xcode's buttons
On a Mac, Xcode creates certificates and profiles with a click. Without one, everything goes through the App Store Connect API with an API key:
- the distribution certificate and the provisioning profile were created through the API, and live as encrypted secrets of the repository;
- the release build signs manually with them, set in the XcodeGen file;
- the build number is the workflow's run number, so it always grows;
- on the runner, the certificate goes into a throwaway keychain created for the run and deleted after it;
- a separate TestFlight workflow, started by hand or by a release tag, archives the release build and uploads it;
- the Firebase configuration also comes from a secret; without it the build bundles a stub, so a pull request from a branch still builds and tests.
An early upload was rejected before it reached TestFlight: a build that supports iPad must support every orientation there, and mine was portrait-only. The apps are iPhone-only for now. Making that stick took one more run: XcodeGen's iOS preset sets the device family on the target, quietly overriding the same setting at project level.
App Store Connect from a script
Most of the App Store page can be set through the API too: texts and screenshots in seven languages, territories, age rating, the in-app purchase. Two things could not:
- App Privacy (what data the app collects) has no API at all; it has to be filled in on the website. A trap there: when the app contains the text of Apple's tracking prompt, the answers cannot be published until at least one data type is marked as used for tracking.
- The first in-app purchase has to be submitted together with an app version, and it is added to the review from the purchase's own page, not from the version's.
What CI cannot replace
A real device. The simulator does not have a real touch screen, real StoreKit or a real speaker, and the recordings cannot tell me how the piano sounds. That is what TestFlight is for: the first time the app runs on an iPhone is a TestFlight build from CI.
And a person in App Store Connect: the privacy answers, the agreements, the final "submit". The rest, the building, testing, signing and uploading, runs from a Linux machine and a YAML file.