EYThe LogEmre Yakut
← all entries
Mobile · Debugging

I have no Mac, and an iOS app

I wrote Fly4cast’s iOS build on Windows, compiled it in the cloud and shipped it to TestFlight. The hard part was not the code — it was signing. Signing is not a build step, it is a chain of identity.

Windows kaynak · git push XcodeGen proje üretilir bulut macOS arşiv + imza TestFlight build n+1 tuzaklar:· dev profili ile App Store arşivi olmaz· .p8 anahtarı printf %b ile yazılır· IPA yolu ios/build/ios/ipa· build no = son TestFlight + 1Mac’im yok. iOS uygulamam var.imzalama bir derleme adımı değil, bir kimlik zinciri — kopan halkayı bul.

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:

piecewhat it iswhen it breaks
Certificatethe key pair identifying you“no valid signing identity”
Profilewhich app, which devices, which entitlementsarchive builds, upload rejected
Entitlementspush, app groups, background modesthe feature silently does nothing
API keythe identity performing the uploadbuild 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

CI's first rule

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”.

MobileDebugging