From abf149829735dba59341ac83f8e5136c03714747 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Thu, 16 Jul 2026 07:23:54 -0400 Subject: [PATCH 1/3] chore(dist): TestFlight archive/export pipeline (T-020) Ship the app to a device owner (Dan) via TestFlight. Bundle ID + team were already in project.yml; this adds the missing distribution pipeline: Tools/build-testflight.sh xcodegen -> archive (Release, generic/iOS) -> export the App Store .ipa. Build number = git commit count (unique + monotonic without editing project.yml). -allowProvisioningUpdates mints the distribution cert/profile on first run. Optional --upload via altool + an App Store Connect API key. Tools/ExportOptions.plist method app-store-connect, team 5HR8E5CWR7, automatic signing, manageAppVersionAndBuildNumber=false so our build number ships. docs/testflight.md one-time App Store Connect setup, build/upload, inviting Dan (internal vs external + public link), and the gotchas (90-day build expiry, the Local Network permission prompt). Verified as far as the account allows: ARCHIVE SUCCEEDED and signs. Export is currently blocked on two account-side steps (accept the updated Program License Agreement; register the app record in App Store Connect) -- documented, not code. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- Tools/ExportOptions.plist | 24 ++++++++++++++ Tools/build-testflight.sh | 66 +++++++++++++++++++++++++++++++++++++++ docs/testflight.md | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 Tools/ExportOptions.plist create mode 100755 Tools/build-testflight.sh create mode 100644 docs/testflight.md diff --git a/Tools/ExportOptions.plist b/Tools/ExportOptions.plist new file mode 100644 index 0000000..19f10e6 --- /dev/null +++ b/Tools/ExportOptions.plist @@ -0,0 +1,24 @@ + + + + + + method + app-store-connect + teamID + 5HR8E5CWR7 + signingStyle + automatic + uploadSymbols + + manageAppVersionAndBuildNumber + + destination + export + + diff --git a/Tools/build-testflight.sh b/Tools/build-testflight.sh new file mode 100755 index 0000000..de936ca --- /dev/null +++ b/Tools/build-testflight.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Build a signed archive and export a TestFlight-ready .ipa (T-020). +# +# Tools/build-testflight.sh # archive + export the .ipa +# Tools/build-testflight.sh --upload # ...and upload to App Store Connect (needs API key, below) +# +# The first archive on a fresh machine mints the Apple Distribution cert + App Store provisioning +# profile automatically (-allowProvisioningUpdates) -- as long as Xcode is signed into the Apple ID +# that owns team 5HR8E5CWR7 (Xcode > Settings > Accounts). No cert wrangling by hand. +# +# BUILD NUMBER: derived from the git commit count, so every archive gets a unique, monotonically +# increasing CFBundleVersion without hand-editing project.yml. TestFlight rejects a re-used build +# number, so commit (or let CI bump) between uploads. The marketing version (0.1.0) still lives in +# project.yml -- bump it there for a user-facing version change. +# +# UPLOAD (--upload) needs an App Store Connect API key (App Store Connect > Users and Access > Keys): +# export ASC_KEY_ID=XXXXXXXXXX # the key's ID +# export ASC_ISSUER_ID=xxxxxxxx-.... # the issuer UUID at the top of that page +# place the AuthKey_.p8 in ~/.appstoreconnect/private_keys/ (altool finds it by ID) +# Without a key, skip --upload and use Xcode's Organizer (Window > Organizer > Distribute App) once; +# it does the same upload through a GUI and is the gentler first-timer path. +set -euo pipefail +cd "$(dirname "$0")/.." + +SCHEME=KitchenSyncApp +PROJECT=KitchenSyncApp.xcodeproj +OUT=build/testflight +ARCHIVE="$OUT/KitchenSyncApp.xcarchive" +BUILDNUM=$(git rev-list --count HEAD) + +echo "==> regenerating the project (project.yml is the source of truth)" +xcodegen generate + +echo "==> archiving $SCHEME (Release, build $BUILDNUM)" +rm -rf "$OUT" +mkdir -p "$OUT" +xcodebuild archive \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE" \ + CURRENT_PROJECT_VERSION="$BUILDNUM" \ + -allowProvisioningUpdates + +echo "==> exporting the App Store .ipa" +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportOptionsPlist Tools/ExportOptions.plist \ + -exportPath "$OUT/export" \ + -allowProvisioningUpdates + +IPA=$(ls "$OUT"/export/*.ipa 2>/dev/null | head -1) +[ -n "$IPA" ] || { echo "no .ipa produced -- check the export log above" >&2; exit 1; } +echo "==> built: $IPA (build $BUILDNUM)" + +if [ "${1:-}" = "--upload" ]; then + : "${ASC_KEY_ID:?set ASC_KEY_ID to upload}" + : "${ASC_ISSUER_ID:?set ASC_ISSUER_ID to upload}" + echo "==> uploading to App Store Connect (TestFlight)" + xcrun altool --upload-app -f "$IPA" -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + echo "==> uploaded. It appears under TestFlight after ~5-15 min of processing." +else + echo "Next: run with --upload (API key set), or open $ARCHIVE in Xcode's Organizer and Distribute App." +fi diff --git a/docs/testflight.md b/docs/testflight.md new file mode 100644 index 0000000..69f97f2 --- /dev/null +++ b/docs/testflight.md @@ -0,0 +1,60 @@ +# Shipping the app to a device owner (TestFlight) + +How a customer like Dan gets the KitchenSync app onto their phone once you ship them a unit. +TestFlight is the right tool while the app is pre-App-Store; the App Store is the eventual home +once you're shipping to more than a handful of people (a free companion app anyone can download, +no per-person invites, no 90-day expiry). + +The bundle ID (`dev.ericdahl.kitchensync`) and team (`5HR8E5CWR7`) are already set in +`project.yml`; automatic signing mints the distribution cert on the first archive. So the moving +parts are: build the `.ipa`, upload it, invite the tester. + +## One-time setup (per app, per machine) + +1. **Xcode account.** Xcode > Settings > Accounts: sign in with the Apple ID that owns the + Developer Program membership for team `5HR8E5CWR7`. This is what lets `-allowProvisioningUpdates` + create the Apple Distribution cert + App Store profile without hand-wrangling. +2. **Register the app in App Store Connect.** appstoreconnect.apple.com > Apps > **+** > New App: + iOS, the `dev.ericdahl.kitchensync` bundle ID (register it under Certificates, IDs & Profiles + first if it isn't there), an app name, primary language, SKU. This app record only has to be + created once; every build uploads into it. +3. **(For scripted upload) an API key.** App Store Connect > Users and Access > **Keys** > **+**. + Give it "App Manager" access. Note the **Key ID** and the **Issuer ID** (top of the page), and + download the `AuthKey_.p8` **once** (you can't re-download it). Put it in + `~/.appstoreconnect/private_keys/`. This is optional — the first time, Xcode's Organizer GUI is + easier (see below). + +## Build a TestFlight build + +```sh +Tools/build-testflight.sh # archive + export build/testflight/export/*.ipa +Tools/build-testflight.sh --upload # ...and upload (needs ASC_KEY_ID / ASC_ISSUER_ID env + the .p8) +``` + +The build number is the git commit count, so it climbs on its own — but TestFlight rejects a +**re-used** build number, so commit between uploads (or bump `CURRENT_PROJECT_VERSION`). + +**First-timer path (no API key):** run `Tools/build-testflight.sh` (no `--upload`), then open the +`.xcarchive` in **Xcode > Window > Organizer > Distribute App > App Store Connect > Upload**. Xcode +handles cert creation, upload, and shows any validation errors inline. Once you've done it once and +have the API key, `--upload` makes it one command. + +## Invite Dan + +App Store Connect > your app > **TestFlight**: + +- Wait for the build to finish **processing** (~5–15 min after upload; you get an email). +- Add Dan as a tester. **Internal** (needs him on your team, no review, instant) or **External** + (by email; the first external build needs a one-time **Beta App Review**, usually cleared within + a day). For shipping to customers, external is normal. +- Dan installs Apple's **TestFlight** app from the App Store, opens your email invite (or a **public + link** you generate under External Testing — anyone with the link can join, no email needed), and + the build installs. Updates you push later just appear for him. + +## Gotchas + +- **Builds expire after 90 days.** Push a fresh build periodically or Dan's copy stops opening. This + is the main reason to move to the App Store once you're shipping in volume. +- **Local Network permission.** On first launch iOS prompts for it; if Dan denies it, the app finds + no devices at all (Bonjour + `.local` both die silently). Worth a line in the ship note to him. +- External testers cap at 10,000; internal at 100. From b0674d2c7e5849d3801e6d2a467f1a3220d17ab5 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Thu, 16 Jul 2026 09:40:24 -0400 Subject: [PATCH 2/3] fix(dist): declare UISupportedInterfaceOrientations (App Store error 90474) An iPad-capable bundle (TARGETED_DEVICE_FAMILY 1,2) must declare orientations or App Store ingestion rejects it for iPad multitasking. iPhone gets portrait + both landscapes; iPad all four. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- project.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/project.yml b/project.yml index 5c54b15..f702f0c 100644 --- a/project.yml +++ b/project.yml @@ -29,6 +29,18 @@ targets: NSLocalNetworkUsageDescription: KitchenSync finds your devices on the local network to show their status and control their MIDI clock. NSBonjourServices: - _http._tcp + # Required for App Store upload: an iPad-capable bundle (TARGETED_DEVICE_FAMILY 1,2) + # MUST declare orientations or ingestion rejects it (error 90474 — iPad multitasking). + # iPhone gets the usual three (no upside-down); iPad gets all four. + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.ericdahl.kitchensync From f70e40657a6f7dcb3cfe424494d1496e1ee8eba9 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Thu, 16 Jul 2026 09:42:25 -0400 Subject: [PATCH 3/3] chore(dist): declare no non-exempt encryption (skip the compliance prompt) The app uses only plain HTTP to local devices + the OS's standard TLS, both export-exempt. ITSAppUsesNonExemptEncryption=false pre-answers App Store Connect's compliance question so each build processes without manual intervention. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- project.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/project.yml b/project.yml index f702f0c..c29f8fb 100644 --- a/project.yml +++ b/project.yml @@ -29,6 +29,11 @@ targets: NSLocalNetworkUsageDescription: KitchenSync finds your devices on the local network to show their status and control their MIDI clock. NSBonjourServices: - _http._tcp + # Export compliance: the app talks plain HTTP to local devices and uses only the + # OS's standard TLS — both exempt — so it declares NO non-exempt encryption. This + # pre-answers the App Store Connect compliance question so each build processes + # straight through. Revisit if the app ever adds its own crypto. + ITSAppUsesNonExemptEncryption: false # Required for App Store upload: an iPad-capable bundle (TARGETED_DEVICE_FAMILY 1,2) # MUST declare orientations or ingestion rejects it (error 90474 — iPad multitasking). # iPhone gets the usual three (no upside-down); iPad gets all four.