← Nesim Apps

Shipping an iPhone App Without a Mac: Kotlin Multiplatform, a Cloud Mac and TestFlight

By Nesim Razon, independent Android developer · 2026-11-14

#Dev notes#iOS

I develop on Windows and I do not own a Mac, but I wanted my small Android game 2048 on the App Store. It turns out you do not need a Mac on your desk, only one in the cloud for a few minutes per build. Here is the pipeline that took a build from my laptop to TestFlight and into App Store review within a day, and the traps I hit on the way.

The shape of the project

The game was already written in Kotlin with Jetpack Compose, so I moved it to Kotlin Multiplatform with Compose Multiplatform. A shared module now holds the game rules, the screens, the saved game, the texts and the font. The Android app is a thin shell around it, and a small Swift app is the iOS shell.

Everything that differs between platforms sits behind one small interface: banner ads, the leaderboard, the rating prompt, sharing. Android implements it with the SDKs it already used. On iOS the Swift side implements it with the Google Mobile Ads SDK and Game Center. One more detail: with Android Gradle Plugin 9 the Android application plugin and the Kotlin Multiplatform plugin can no longer share a module, so the shared code lives in its own library module.

What Windows can and cannot do

Kotlin compiles iOS code on Windows. Tasks such as the iOS simulator compile run locally and report Kotlin errors, so most of the shared code and the iOS-specific Kotlin could be checked at my desk. What Windows cannot do is link the iOS framework, build the Swift app or sign anything. That part needs macOS.

The practical effect is that I never wait for a Mac to find a Kotlin mistake, but a Swift mistake costs one CI run, because I write the Swift side without a compiler at hand. I kept the Swift code small for exactly that reason.

No Xcode project file in the repository

Without Xcode there is no project file to edit by hand, so I describe the app in a short YAML file and let XcodeGen create the project on the build machine. The YAML holds the bundle identifier, the Info.plist values, the Game Center entitlement, the Swift packages for ads and a pre-build script that asks Gradle to build and embed the shared framework.

A broken YAML file costs a build start. Mine broke once, when a long list lost a line break and the job failed within seconds, so now I parse the file locally with a small script before every push.

The cloud Mac

The build runs on a GitHub Actions macOS runner. The job checks out the code, sets up Java and the Gradle cache, installs XcodeGen with Homebrew, generates the project and runs xcodebuild. On the runner I used, the installed Xcode was a recent 26.x release, which matters because uploads to App Store Connect require a current SDK.

A push to the iOS branch always runs a quick unsigned simulator build. A commit message containing the word testflight switches to the other job instead, which archives, signs and uploads. That way a normal push stays cheap and I decide when a build goes to Apple.

Signing without a Mac

You still need an Apple Developer membership, but you do not need a Keychain on a Mac. In App Store Connect you create an API key and keep the key file, the key ID and the issuer ID as repository secrets. xcodebuild accepts them as arguments together with the flag that allows provisioning updates, and Apple then creates and manages the certificates and profiles in the cloud.

The export step uses an options file with the method set to app-store-connect and the destination set to upload, so the same command that exports the archive also uploads it to TestFlight.

The one real trap was the role of the key. A key with the App Manager role let the archive step succeed, but the export failed with a cloud signing permission error. A new key with the Admin role fixed it.

What it costs

On a free GitHub plan the included usage is measured in dollars. At the time of writing a macOS runner minute costs about ten times a Linux minute. My first full day of experiments used 54 macOS minutes, a little over three dollars of included credit, and a single TestFlight run takes around six minutes.

Two habits keep it cheap: run the simulator build only when I did not ask for an upload, and check files locally before pushing. If the included credit ever runs out, the runs simply stop, so there is no surprise bill on a plan without a payment method.

Small traps worth knowing

Testing on a real phone

The build runs on an iPhone 7 on iOS 15.8 through TestFlight. Setting the deployment target to iOS 15 was enough for that, and an old phone is a useful low-end test device. After a few rounds the game played the same as on Android, with ads and leaderboard working through the Swift side.

Frequently asked questions

Do I need a Mac to publish an app on the App Store?

Not on your desk. You need a Mac build machine, but a cloud runner such as a GitHub Actions macOS runner does that job. You still need an Apple Developer membership.

Can Windows compile Kotlin code for iOS?

Yes for the Kotlin part: the iOS targets compile on Windows. Linking the framework, building the Swift app and signing need macOS.

How is the app signed without a Mac?

With an App Store Connect API key stored as a secret. Xcode on the cloud runner uses it for cloud-managed signing and uploads straight to TestFlight.

How long does a cloud build take?

A TestFlight run took about six minutes in my setup, a bit more on the first run when nothing was cached.

More from the blog