# Shipping a Flutter app to iOS without a Mac — getting code signing to pass on Codemagic ([日本語](flutter-ios-codemagic.md)) A record of getting a Flutter app onto App Store Connect and into TestFlight without owning a Mac. **Code signing alone failed seven times.** Every failure was the same shape: **the error named something other than what was actually missing**, so acting on the message kept me going the wrong way. This is for anyone stuck at the same place. Certificates and provisioning profiles are all created by Codemagic. **Xcode's export flow was never used once.** --- ## The failures, and what was actually missing | Error shown | What was actually missing | | --- | --- | | `No development certificates available` | The Runner target had no signing settings | | `Runner requires a provisioning profile` | **The private key (`CERTIFICATE_PRIVATE_KEY`) wasn't registered** | | `No matching profiles found` | Trying to use a profile before creating it | | `Path .../export_options.plist does not exist` | Two commands disagreed on the output path | **One root cause outlasted all of them** — the missing `CERTIFICATE_PRIVATE_KEY`. **A certificate is generated from a private key, so without the key no command can create one.** The answer was in the log all along (`Cannot save Signing Certificates without certificate private key` / `Provisioning Profiles: []`). **I misdiagnosed it twice by reading only the config file and not the log.** **Read the log, not the message.** --- ## Setup (browser work) ### 1. Enrol in the Apple Developer Program - **Organization enrolment fails unless D&B lists your entity as incorporated.** Your D-U-N-S number is recognised, but you're rejected with "we could not verify that this organization is a legal entity." Sole proprietors end up enrolling as an Individual - **As an Individual, your App Store seller name is locked to your legal name.** Only an Organization can display a trading name, and only when registering its first app - Activation took **about two hours** after payment. No phone call ### 2. Register the Bundle ID `developer.apple.com` → Certificates, Identifiers & Profiles → Identifiers → + - **Apple does not allow `_` in a bundle ID** (letters, digits, hyphens and periods only). You may not be able to reuse your Android applicationId as-is. **If they can't match, decide that they won't and write it down** — otherwise the next person reads the mismatch as a bug - **Register it here before creating the app in App Store Connect.** If you don't, it won't appear in the dropdown - Leave the Capabilities checkboxes alone (neither Firebase nor ads need anything here) ### 3. Create the app in App Store Connect `appstoreconnect.apple.com` → My Apps → + → New App - This issues the **App Store ID** (10 digits), visible on the App Information page - **No build required.** You're reserving a name and a slot - App Store gives you a **subtitle field (30 characters)** that Google Play doesn't have ### 4. Create an App Store Connect API key `appstoreconnect.apple.com` → Users and Access → Integrations → App Store Connect API - You need **three things: Issuer ID, Key ID, and the `.p8` file** - **The `.p8` downloads exactly once.** Store it outside your repository - Register it in Codemagic **under a name per app** (referenced from `codemagic.yaml`) ### 5. Create a private key for the certificate **No Mac needed. This runs in Git Bash on Windows.** ``` ssh-keygen -t rsa -b 2048 -m PEM -f ios-cert-private-key -q -N "" ``` - **Without this key no certificate can be created.** Certificates are generated from a private key - **Store it outside your repository** - **Losing it means recreating the certificate, and Apple caps you at three distribution certificates** — so it costs you one of the three ### 6. Register environment variables in Codemagic App settings → Environment variables. **Tick Secret on both.** | Variable | Value | | --- | --- | | `CERTIFICATE_PRIVATE_KEY` | The full key from step 5 (`-----BEGIN` through `-----END`) | | `GOOGLE_SERVICE_INFO_PLIST` | `GoogleService-Info.plist`, base64-encoded (if you use Firebase) | Files excluded by `.gitignore` can't travel through the repository, so restore them from environment variables. ``` base64 -w0 ios/Runner/GoogleService-Info.plist ``` **The API key from step 4 does not go here.** That one is registered in Codemagic's integrations and referenced by name from `integrations.app_store_connect`. **Gotcha: give your variables a group name.** Codemagic only passes variables into a build via groups. **A variable registered without a group arrives empty — and nothing errors, so you won't notice.** List the same group name under `environment.groups` in `codemagic.yaml`. --- ## codemagic.yaml — the parts that break if you change them ### Split signing into three stages ```yaml - name: Set up signing script: | app-store-connect fetch-signing-files "$BUNDLE_ID" \ --type IOS_APP_STORE \ --create keychain initialize keychain add-certificates xcode-project use-profiles \ --project ios/Runner.xcodeproj \ --export-options-plist $CM_BUILD_DIR/export_options.plist ``` - **Keep `--create`.** On the first run no provisioning profile exists, so unless you let it create one, the next command fails with "not found" - **Do not add `create-certificate`.** It demands the same private key, so it stalls at the same place when the key is missing — and it has no "only if absent" condition, so **every build adds another certificate and you hit the cap of three** - **Always run `use-profiles`.** It writes the three signing settings (`CODE_SIGN_STYLE`, `DEVELOPMENT_TEAM`, `PROVISIONING_PROFILE_SPECIFIER`) into the Xcode project. **The Runner target Flutter generates has none of them**, and an old `iPhone Developer` left at project level was the only thing in effect — the cause of the first two failures ### Don't build the ipa with `flutter build ipa` ```yaml - name: Build the ipa script: | xcode-project build-ipa \ --project ios/Runner.xcodeproj \ --scheme Runner \ --export-options-plist $CM_BUILD_DIR/export_options.plist ``` - `flutter build ipa` **drops into Xcode's automatic signing partway through the archive and fails looking for a development certificate.** The manual settings `use-profiles` wrote aren't used on that path - **Don't run `flutter build ios --no-codesign` first.** It produces an unsigned `Runner.app` that the following archive step rebuilds anyway. That was the third failure, and **the 77 seconds it spent were thrown away** - **`--export-options-plist` must match the path `use-profiles` wrote to** ### Log what was written ```yaml xcodebuild -project ios/Runner.xcodeproj -target Runner -showBuildSettings 2>/dev/null \ | grep -E "CODE_SIGN_STYLE|DEVELOPMENT_TEAM|PROVISIONING_PROFILE" \ || echo "(no signing settings found)" ``` Next time it fails, **the log alone tells you whether the settings were written.** --- ## Getting it onto a device with TestFlight **Internal testing needs neither review nor the test information form.** External testing is a different thing. | | Audience | Review | Test information | | --- | --- | --- | --- | | **Internal** | Your own team (up to 100) | **Not required** | **Not required** | | **External** | Anyone (up to 10,000) | Required (first time) | Email, contact name, phone | - **For device testing you want internal, so set `submit_to_testflight: false`.** Left as `true`, **it fails on the very last step after the build and upload both succeeded** (`App is missing required Beta App Review Information`). No rebuild needed — add the build to internal testing from the web UI - **Only people with an App Store Connect account can be internal testers.** Sharing with a friend requires external testing - **Open TestFlight from the link in the invitation email.** If you install TestFlight first and open it directly, you get stuck on a redeem-code screen — **internal testing doesn't issue codes** Source: Apple's documentation at `developer.apple.com/help/app-store-connect/test-a-beta-version/`, which states that both review and the information form apply **when distributing to external testers.** **Bump the build number every time.** The same number is never accepted twice (`The bundle version must be higher than the previously uploaded version`). Flutter's `pubspec.yaml` is shared with Android, so **bumping it also moves your Play versionCode.** The two stores' numbers are unrelated, so there's no need to keep them in sync. --- ## When the device shows a blank screen **Check whether `GoogleService-Info.plist` is registered in the Xcode project.** ``` grep -c "GoogleService-Info" ios/Runner.xcodeproj/project.pbxproj ``` **Zero means that's your cause.** Placing the file during the build isn't enough: **Xcode only bundles files registered in the project.** `Firebase.initializeApp()` reads it, throws, and never reaches `runApp()`. **No crash log appears.** A Dart exception isn't recorded as an iOS crash, so you get "the app is alive but nothing is drawn" — with nothing to diagnose. Fix it by adding the file in four places, the same way `AppFrameworkInfo.plist` appears (PBXBuildFile, PBXFileReference, the Runner group's children, and the Resources build phase). **Commit it once rather than registering it during each build** — the pbxproj holds only a reference, **not the contents of the plist**, so no secrets get committed. **Also wrap your startup in a try and put the failure on screen.** A blank screen carries no information. **Build that error screen with plain Flutter widgets and none of your own theme** — if the theme is what broke, a themed error screen is blank too. --- ## What I missed when porting from Android **iOS pulls in an entirely different set of dependencies.** Verification that passed on Android does not carry over. Watching only the signing, I missed four things and shipped to App Store Connect anyway, then had to rebuild. - **The ads SDK's app ID and ad unit ID.** Flutter's template ships Google's **public test IDs**. iOS needs its own, issued separately - **The licence list.** Calling `registerAndroidLicenses()` without a platform check **lists Android components that don't exist on iOS, and none of the iOS SDKs that do** - **Without a Mac you have no local `Package.resolved`.** Read **`Fetching from ...` in the Codemagic build log** to see what iOS actually pulls in - **Watch for `.binaryTarget`.** Google's ads SDK has an Apache 2.0 LICENSE in its repository, but its `Package.swift` points at prebuilt artefacts, **and what actually ships is governed by Google's terms.** Reading only the repository LICENSE gets it wrong - In practice **three of them were not Apache 2.0** (LevelDB = BSD 3-Clause, nanopb = zlib, ads SDK = Google's terms) - **The export compliance declaration** (`ITSAppUsesNonExemptEncryption`). Without it **you're asked the same question on every upload.** `false` if you implement no encryption of your own - **An ATT usage string (`NSUserTrackingUsageDescription`) with no code that requests it.** The dialog never appears, so the screen promises something the app never does --- ## Environment - Flutter 3.47.1 / Xcode latest / CocoaPods default - Codemagic `mac_mini_m2` - Development machine is Windows (Git Bash). No Mac was used at any point - Recorded 2026-08