Blog · August 14, 2026

Our build passed validation. Apple rejected it anyway.

In June we shipped ShotCanvas 1.0 to the App Store — on the fourth try. The first three release builds all died the same way: every local check green, upload accepted, binary processed, and then a rejection email minutes later for a problem that wasn't anywhere in our code. Here's the post-mortem: the error that names the wrong culprit, the checks Apple only runs after upload, and the build-machine rule we now treat as a release blocker.

Minutes from "valid" to INVALID_BINARY

The sequence was maddeningly consistent. Build the release IPA. Run Apple's own pre-flight, xcrun altool --validate-app — passes. Upload — accepted. App Store Connect shows the binary processing, then lists it against the version, everything looking exactly like a healthy release. Then, minutes later, the version status flips to INVALID_BINARY and the email arrives: ITMS-90111, "Unsupported SDK or Xcode version." The build vanishes from the version as if it never happened.

No line number, no file, no API name. Just a sentence saying our toolchain wasn't acceptable — from the same pipeline that had validated that toolchain's output moments earlier.

We chased the noun in the error message

The message says "Xcode version," so we investigated Xcode. We were on a current release, but a newer one existed, so we updated and rebuilt. Build one: rejected. Rebuilt clean and re-uploaded in case something transient had happened. Build two: rejected, identical email. We moved to the newest Xcode available, rebuilt again. Build three: rejected, identical email.

Three uploads, one theory, zero progress. That's past the point where persistence stops being a virtue: when the same fix fails twice, the fix isn't under-executed — the theory is wrong. The useful move was to stop rebuilding and re-read the situation: what does Apple know about this binary that altool doesn't check?

The culprit was under the toolchain, not in it

The Mac doing the builds was running a beta of macOS. That was the entire problem. Every binary built on a Mac records the OS build of the machine that produced it — there's a BuildMachineOSBuild key sitting in the compiled app's Info.plist — and Apple's ingestion pipeline rejects anything stamped with an unreleased operating system. It reports that rejection under the same ITMS-90111 code it uses for outdated Xcode, and the message never mentions the OS at all.

So: release Xcode, release SDK, release iOS target — built on a beta macOS — is a rejection, and the error will tell you to check your Xcode version. Apple's developer forums are full of the same confusion: people insisting they have no betas installed, because the beta isn't in their toolchain. It's the floor the toolchain is standing on.

This trap has a season, too. Every summer a new macOS beta ships, and every summer some developers put it on their daily-driver Mac — which for a solo dev is also the build machine. Nothing complains for weeks, because day-to-day development, simulators, and device installs all work fine. The failure only appears at the worst moment: the next time you try to ship a release. And by then there's no quick undo — leaving a macOS beta generally means a full reinstall, not a settings toggle, which is exactly why the eventual fix is "build somewhere else" rather than "roll back."

Why "validation passed" means less than it sounds

The part of this that generalizes beyond our specific mistake: there are two gates between your IPA and the store, and the one you can run locally is the shallow one. altool --validate-app (and Transporter's verify) is a pre-flight checklist — packaging, signing, obvious metadata problems. The checks that actually decide your binary's fate run server-side after upload, during "processing," and some verdicts are only ever delivered by email with an ITMS code attached. The console can show your build as perfectly healthy right up until it doesn't.

Treat a green local validation as "worth uploading," never as "will be accepted." And when a rejection email does land, the ITMS code in the body is the real message — it's specific, searchable, and usually far more informative than the status App Store Connect shows you.

The fix: any machine running a released OS

Once the theory was right, the fix was boring: build somewhere with a release operating system. A second Mac on stable macOS works. So do CI macOS runners. We went with Xcode Cloud — Apple's hosted CI runs release macOS images, and 25 compute hours a month are included with the developer program, which covers a lot of indie release trains. Our first cloud build processed cleanly, no email arrived, and the version moved to Prepare for Submission. That binary is the one that went live.

If you're on Flutter or anything CocoaPods-based, three things will save you a day of Xcode Cloud debugging. Point the workflow at Runner.xcworkspace, not the .xcodeproj — building the bare project skips the pods and the archive fails with every plugin module "not found." Add a ci_post_clone.sh that installs your pinned Flutter version, runs pub get, and runs pod install before the archive starts. And expect Xcode Cloud to overwrite your build number with its own run counter — the number in your pubspec or project file doesn't survive the trip.

The checklist version: the ITMS code in the rejection email is the real message, not the status in the console · the same fix failing twice means the theory is wrong · your build machine's OS ships inside the binary, so a beta on the ship machine is a release blocker · local validation is pre-flight, not review.

The part we can laugh about now

The app made it out, and the listing it shipped with — screenshots included — was built with our own tool, which felt only fair. The larger lesson stuck, though: your build environment is part of your app. It deserves the same "is this actually release-grade?" scrutiny as anything you'd put in the binary on purpose.

See what we finally shipped How long does App Review take?