usharik.dev

← All articles

Shipping an iOS app without a Mac: GitHub Actions as the build machine

Published · · 6 min read

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

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:

  1. Picks a simulator by query, not by name. Device names change with every Xcode release, so the script asks simctl for the newest iOS runtime and the first iPhone on it.
  2. 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.
  3. Generates the Xcode project with XcodeGen from one YAML file. The .xcodeproj is not in git, so there are no merge conflicts in a file nobody can read.
  4. 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.
  5. 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:

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:

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:

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.

Apps in this article