Shipping a Flutter Web Game to TestFlight: the iOS Tax
The nonogram ran on the web for two years before it ran on an iPhone. Same Dart, same widgets, same game loop. Getting that identical code through Apple’s front door turned out to be its own project — not a build step, a project. This is the part the TestFlight arc post waved at when it said “1.1.0 went to TestFlight today.” Here’s what that day actually cost, and the runbook I wrote so the next day costs less.
The web deploy is one line in CI: flutter build web --base-href /nonogram/, push, done. iOS is not one line. iOS is a chain of small, undocumented-until-you-hit-them failures, each of which stops the whole thing cold. I’ll walk the chain in the order it bit me.
The SDK is installed. The platform is not.
First archive attempt:
iOS X.Y is not installed. Please download and install the platform
from Xcode > Settings > Components
This one is a genuine head-scratcher because xcodebuild -showsdks shows the iOS SDK right there. The trap: the SDK that ships with Xcode is not the same thing as the device platform support files (the DDI) that xcodebuild archive checks against for the Any iOS Device destination. SDK present, runtime missing.
xcodebuild -downloadPlatform iOS
Five minutes of download and the archive proceeds. Nothing about the error message points you here.
Signing wants to “help,” repeatedly
A web build doesn’t know what a code-signing identity is. An App Store archive cares about almost nothing else.
The failure mode: archives kept getting signed with Apple Development (“iPhone Developer”) instead of Apple Distribution, and a dev-signed archive can’t be uploaded to App Store Connect. The fix was to pin the Runner target’s Release configuration to manual signing in project.pbxproj, pointing at the App Store distribution profile:
CODE_SIGN_IDENTITY = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Nonogram App Store";
The maddening part: opening the project in Xcode’s GUI and clicking around can silently flip Release back to “Automatically manage signing,” which re-introduces the dev-cert failure. So the rule in my runbook is in bold: keep Debug on Automatic, Release on Manual, and do not let Xcode “fix” it. The setting you fought for is one stray checkbox away from reverting.
altool will not take your password
The upload step uses xcrun altool --upload-app. Feed it your normal Apple ID password and it returns:
-22910 Please sign in with an app-specific password
You have to mint an app-specific password at appleid.apple.com — a xxxx-xxxx-xxxx-xxxx token — and paste it verbatim. Which surfaced a second, dumber problem I’ll get to in a moment.
Export compliance, answered once
Every build normally triggers an export-compliance prompt in App Store Connect — “does your app use encryption?” — that you have to click through before the build can go to testers. You can answer it permanently in Info.plist:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
The app only talks HTTPS, which is exempt under Apple’s standard policy. With that key baked in, a build flips from “Processing” straight to “Ready to Test” with no manual click. One line of plist removes a manual step from every future release.
The 500 that isn’t a failure
The best trap for last. The upload finishes and altool throws:
CHANGE UPLOAD STATE TO COMPLETE (UPLOADING SPI ANALYSIS):
received status code 500; internal server error.
Instinct says: it failed, run it again. Don’t. This is Apple-side noise at the final state-transition step, and the upload itself has almost always already succeeded. Re-running bumps the version and produces a second build for the same change — and then you hit must be higher than previously uploaded version, because App Store Connect’s previousBundleVersion is now ahead of your local pubspec.yaml.
The rule: check the TestFlight tab before rebuilding. If the build is sitting there as Processing, do nothing. Only treat the 500 as real if the build hasn’t appeared after about five minutes — and even then, retry just the upload against the already-built IPA, not the whole pipeline.
Wrapping the whole thing in a script
Every one of these is now encoded in scripts/release-testflight.sh and its runbook, docs/RELEASE_TESTFLIGHT.md. The script:
- Reads the current version from
pubspec.yaml, proposes the next, promptsY/n. - Rewrites
pubspec.yaml, thenflutter clean && flutter pub get. - Writes
ExportOptions.plist(manual signing, team, distribution cert, App Store profile) if it’s missing. flutter build ipa --release— the slow part, ~6–8 minutes.- Prompts for the Apple ID and app-specific password, then
xcrun altool --upload-app. - Prints the post-upload checklist, including the commit-the-bump reminder.
./scripts/release-testflight.sh # auto-bump build number
./scripts/release-testflight.sh 1.2.0 # set marketing version, auto-bump build
./scripts/release-testflight.sh 1.2.0 5 # set both explicitly
Two footnotes the script header makes loud, because both cost me time:
- Run it in a real Terminal, not inside Claude Code. The upload prompts interactively for the Apple ID and password, and an agent harness doesn’t relay those — the step appears to hang. I do most of this project with Claude; this is the one task that has to leave the room.
read -rspis a bash-ism. Retrying the upload by hand in zsh givesread: no coprocess, because zsh’sread -preads from a coprocess, not a prompt. Wrap the manual retry inbash -c '...'.
What the iOS tax actually is
None of these are hard problems. Each is a five-minute fix once you know it. The tax isn’t difficulty — it’s that the chain is serial and opaque: every link fails with a message that doesn’t tell you which link broke, and you only get to the next failure after clearing the last one. The first release is a day of this. The second should be twenty minutes.
That gap — day one versus twenty minutes — is the entire reason the runbook exists. Shipping isn’t done when the thing uploads once. It’s done when uploading is boring. A side project that can’t cut a release without rediscovering all five traps hasn’t really shipped; it’s just gotten lucky once. Writing the failures down, in the order they happen, with the exact commands, is what converts luck into a process.
Part of a short series on the engineering behind the nonogram app — see also the CI bot that approves my screenshots and the puzzle solver.