Martin Harris

"Ceci n'est pas une API Gateway" — René Magritte (paraphrased)

Making cloud abstractions tangible through physical hardware.

Chat with me →

The Bit After the Code

The Bit After the Code

A smartphone on a wooden desk with a warm coffee mug beside it, showing a clean recipe app interface, soft window light, shallow depth of field, editorial photography style

The app was done. Or done enough — a Share Extension that plucks recipes off web pages, an AI extraction fallback for the ones that don't publish structured data, a SwiftUI list view, DynamoDB on the backend, Cognito for auth. My wife Yvonne had been waiting for it for months. All I had to do was get it onto her iPad.

That took an entire Saturday afternoon. In the end I sideloaded it directly via Xcode mid-morning so she could actually use it, then spent the rest of the day fighting the deployment pipeline so future builds would go out properly. She was happily saving recipes by lunch. I was not.

This is a post about the bit after the code: TestFlight, App Store Connect, code signing, screenshot requirements, and fastlane saying no in more ways than I'd care to count.

fastlane fails almost immediately

I already had fastlane set up. The beta lane builds the app and uploads to TestFlight. The build number is derived from git rev-list HEAD --count — monotonically increasing, no API calls, good enough for a single-branch workflow. (If you build from multiple branches, be aware this can jump backwards; a CI-managed counter or per-branch offset keeps TestFlight numbers reliably monotonic.) I ran it.

[!] Git repository is dirty!

The ignored/ directory, containing the Evernote import scripts I'd never tracked, was showing as untracked. Not dirty exactly, but fastlane's ensure_git_status_clean counts untracked files. Fair enough. I added it to .gitignore.

Ran it again. This time: ios/fastlane/README.md and ios/fastlane/report.xml — auto-generated by fastlane itself. Added those too. Then on the next run, increment_build_number had run on the previous failed attempt and left the tracked Info.plist files dirty. Reset them with git checkout -- Info.plist.

The underlying lesson: audit your .gitignore before the first run, and add a git checkout -- Info.plist step as a preflight or cleanup action so reruns start clean. Committing the whole fastlane/ directory up front is the other approach — it avoids chasing generated files one at a time.

Eventually it built.

The archive succeeds. The export doesn't.

error: exportArchive No profiles for 'com.nakomis.recipator' were found

The project uses automatic signing. For debug builds in Xcode, automatic signing just works — partly because Xcode is running interactively, signed in with a developer account. For an app-store export from the command line, it needs permission to create or fetch a distribution profile. The fix is one argument, and it's worth adding to both the build and export phases to cover all the places provisioning may need to update:

build_app(
  scheme:        "Recipator (Production)",
  export_method: "app-store",
  xcargs:        "-allowProvisioningUpdates",
  export_xcargs: "-allowProvisioningUpdates",
)

The upload needs an API key, not a password

The IPA built. The upload failed:

Missing password for user martin@nakomis.com, and running in non-interactive shell

Fastlane was trying to authenticate with my Apple ID and password — which works fine interactively, but not in a background process. The reliable solution is an App Store Connect API key: generated in App Store Connect under Users and Access → Keys (App Store Connect API), downloaded as a .p8 file, no passwords, no 2FA, works in CI. Password-based auth is brittle and generally unsuitable in CI due to 2FA prompts; App Store Connect API keys are the reliable path. The .p8 file itself should never be committed to source control; scope the key to the minimum role needed.

The simplest approach is to use the key file exactly as downloaded from Apple — app_store_connect_api_key can read the .p8 content directly without any encoding. I wanted a keychain-backed local store that would also fall back to environment variables for CI, so I stashed three pieces (Key ID, Issuer ID, raw .p8 content) in the macOS keychain using security add-generic-password. The Key ID and Issuer ID aren't secrets, but stashing them in the "password" field of their own keychain items means they can be pulled out with the same -w flag as the actual key content — the naming is just for retrieval convenience, not because they're passwords:

asc_key_id      = ENV["ASC_KEY_ID"]      || `security find-generic-password -s app-store-connect -a key-id      -w`.strip
asc_issuer_id   = ENV["ASC_ISSUER_ID"]   || `security find-generic-password -s app-store-connect -a issuer-id   -w`.strip
asc_key_content = ENV["ASC_KEY_CONTENT"] || `security find-generic-password -s app-store-connect -a key-content -w`.strip

Those values then get passed to the upload action:

upload_to_testflight(
  api_key: app_store_connect_api_key(
    key_id:      asc_key_id,
    issuer_id:   asc_issuer_id,
    key_content: asc_key_content,
  )
)

If you store the key content as base64 instead of raw PEM, add is_key_content_base64: true to the app_store_connect_api_key call — without it, fastlane will fail to parse the key.

Apple rejects the upload for orientation reasons

The upload worked. Apple's servers chewed on it for a minute and then:

Invalid bundle. The orientations
"UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft,
UIInterfaceOrientationLandscapeRight" were provided for the
UISupportedInterfaceOrientations Info.plist key, but you need to include all of
"UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown,
UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight"
to support iPad multitasking.

If your iPad app supports multitasking (i.e. UIRequiresFullScreen is not set to true), you must declare all four orientations, including upside-down portrait. The build had actually warned about this (All interface orientations must be supported unless the app requires full screen) but warnings in a long build log are easy to miss. One block added under the iPad target's info: dictionary in project.yml (Apple's device modifiers are lowercase — ~ipad, not ~iPad, or the key is silently ignored):

UISupportedInterfaceOrientations~ipad:
  - UIInterfaceOrientationPortrait
  - UIInterfaceOrientationPortraitUpsideDown
  - UIInterfaceOrientationLandscapeLeft
  - UIInterfaceOrientationLandscapeRight

XcodeGen regenerated, rebuilt, re-uploaded.

Skipping the encryption compliance dialog

While on the subject of things that cause unnecessary upload round-trips: the encryption compliance dialog that appeared next is avoidable. Adding ITSAppUsesNonExemptEncryption = false to Info.plist eliminates it permanently — but set this only if you use no custom encryption beyond Apple's TLS/ATS. Recipator only uses HTTPS, so this is accurate, and future builds skip the dialog entirely. If you later add non-exempt cryptography (CryptoKit with custom schemes, for example), you'll need to remove or update this key to stay compliant.

A macOS terminal window showing a long sequence of fastlane build steps, green success checkmarks and red error lines alternating, dark theme, even lighting

A sign-out bug that was actually a session-sharing bug

Waiting for Apple's servers to process each upload left me staring at the app — which is exactly when you notice the edge cases you'd been ignoring. After signing out, tapping "Sign In" would skip the login form and authenticate automatically. Cognito's hosted UI was reusing a session cookie from Safari.

The fix sounds like it would break autofill, but doesn't. Setting prefersEphemeralWebBrowserSession = true on ASWebAuthenticationSession means the browser session doesn't share cookies with Safari — so Cognito can't auto-authenticate. But iOS Password AutoFill operates at the OS level, independently of browser session sharing. The credential sheet still appears; the user still gets one-tap autofill. They just can't be signed in without explicitly doing so. The trade-off is you won't get Safari SSO or managed-account cookie reuse — for a consumer app this was exactly what we wanted, but if you're building for enterprise SSO flows it may not be.

The previous approach — calling Cognito's /logout endpoint to clear the server-side session — was showing a "Recipator Wants to Use login.nakomis.com" system prompt immediately on sign-out, then hitting Cognito's "Invalid request" error because the logout_uri was wrong. Simpler to eliminate it entirely.

Screenshots for a phone that doesn't exist yet

App Store submissions require screenshots at specific pixel dimensions. The iPhone 17 Pro Max simulator (the largest available at the time of writing) produces screenshots at 1320×2868 pixels (6.9"). App Store Connect has accepted the 6.9" slot directly since the 16 Pro Max shipped in 2024, so those can go up as-is. What actually caught me out was the older size classes: App Store Connect still lists 6.7" and 6.5" as valid submission slots for listings that don't supply a dedicated 6.9" set, and I wanted mine covered too. The aspect ratios are nearly identical, so a straight resize to 1284×2778 (6.7") works without cropping or distortion, and as of June 2026 uploading the 6.7" size also satisfies the 6.5" slot. One sips command, built into macOS:

sips -z 2778 1284 iphone-screenshot.png --out iphone-screenshot-6_7.png

These dimensions and slot requirements are current as of mid-2026 — always verify against Apple's current Screenshot specifications in the App Store Connect Help before each submission, since Apple adds and retires size classes as new devices ship. If you want to remove the manual step entirely, fastlane's snapshot action captures screenshots on the required simulators at the correct sizes; deliver then uploads them.

For the iPad, the latest 13-inch iPad Pro simulator produces 2064×2752, which Apple does accept for the 13" slot.

What I actually learned

The things the documentation doesn't quite tell you:

  • -allowProvisioningUpdates is the missing flag for command-line app-store exports with automatic signing. Add it to both xcargs and export_xcargs to cover all the places provisioning may need to update.
  • App Store Connect API keys are the reliable path for CI. Password-based auth is brittle and generally unsuitable in CI due to 2FA. Keep the .p8 out of source control and scope the key to the minimum role needed. If you store the key content as base64, set is_key_content_base64: true.
  • If you support iPad multitasking, you must declare all four orientations — including upside-down portrait — or the upload is rejected outright.
  • ITSAppUsesNonExemptEncryption = false in Info.plist eliminates the compliance dialog permanently — but only set it if you use no custom encryption beyond Apple's TLS/ATS, and update it if that changes.
  • prefersEphemeralWebBrowserSession = true prevents Cognito SSO auto-login without breaking Password AutoFill — the two mechanisms are independent. You do lose Safari SSO, which matters for enterprise flows but not consumer apps.
  • 6.9" screenshots are accepted directly, but older slots still need covering — resize to 1284×2778 (6.7") for listings without a dedicated 6.9" set. As of June 2026, the 6.7" size also satisfies the 6.5" slot, but check Apple's current Screenshot specifications before each submission.

By the time the submission went in, it was evening. Yvonne had been using the app all afternoon. The build sitting in TestFlight review felt almost anticlimactic by that point.


Martin Harris connects cloud infrastructure to the real world. If you've ever spent longer getting an app onto your wife's iPad than you did writing it, you're in the right place.


If you find this blog useful, please consider a donation.