Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Tools/ExportOptions.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Export options for App Store Connect / TestFlight distribution (see Tools/build-testflight.sh).
method app-store-connect is Xcode 15+'s name for what used to be "app-store".
manageAppVersionAndBuildNumber is false so the build number we pass on the archive command
(git commit count) is the one that ships -- Xcode otherwise silently rewrites it.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>5HR8E5CWR7</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>destination</key>
<string>export</string>
</dict>
</plist>
66 changes: 66 additions & 0 deletions Tools/build-testflight.sh
Original file line number Diff line number Diff line change
@@ -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_<ASC_KEY_ID>.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
60 changes: 60 additions & 0 deletions docs/testflight.md
Original file line number Diff line number Diff line change
@@ -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_<KEYID>.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.
17 changes: 17 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ 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.
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.ericdahl.kitchensync
Expand Down
Loading