From f772f4e4c4be5f75d665df785deb9cf3c0e217d1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 1 Nov 2026 10:08:55 -0500 Subject: [PATCH] Watch: TestFlight archive, iOS stub container, testflight.sh The App Store only accepts a watch-only app inside an empty iOS stub (watchapp2-container), the pair Xcode's Watch-only App template makes. The stub takes org.gbrown.wristcode; the watch app, and so the APNs topic, becomes org.gbrown.wristcode.watchkitapp. Adds ITSAppUsesNonExemptEncryption = NO and scripts/testflight.sh, which archives with API key automatic signing and a timestamp build number, then exports and uploads (or exports only with --no-upload). Co-Authored-By: Claude Opus 5.5 --- watch/README.md | 27 +++++++++-- watch/WristCode/Info.plist | 2 + watch/project.yml | 27 ++++++++++- watch/scripts/testflight.sh | 89 +++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100755 watch/scripts/testflight.sh diff --git a/watch/README.md b/watch/README.md index 8c6a09c..d7c566f 100644 --- a/watch/README.md +++ b/watch/README.md @@ -13,6 +13,27 @@ xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test ``` +## TestFlight + +The App Store only takes a watch-only app inside an empty iOS stub, so `project.yml` has two app +targets, the same pair Xcode's "Watch-only App" template makes: + +| Target | Bundle ID | Job | +| --- | --- | --- | +| `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app | +| `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic | + +```sh +scripts/testflight.sh # archive, export and upload to App Store Connect +scripts/testflight.sh --no-upload # archive and export an .ipa to build/export- only +``` + +The build number defaults to a `YYYYMMDDHHMM` timestamp (override it with `BUILD_NUMBER`). Signing is +automatic through the App Store Connect API key in `ASC_KEY_PATH`, `ASC_KEY_ID` and `ASC_ISSUER_ID`, +which default to the key in `~/.appstoreconnect/private_keys`. The entitlements file says +`aps-environment: development`; the App Store export re-signs it as `production`. Release builds +register their token with the gateway as `production`, Debug builds as `sandbox`. + ## Demo mode `DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes: @@ -20,9 +41,9 @@ Allow moves a thread to working, a reply restarts a finished thread. The SwiftUI the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: ```sh -xcrun simctl launch org.gbrown.wristcode -demo # feed -xcrun simctl launch org.gbrown.wristcode -demo -thread littleman/t-next # thread -xcrun simctl launch org.gbrown.wristcode -demo -screen new # new thread +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo # feed +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen new # new thread ``` `screenshots/` holds the screens captured this way. diff --git a/watch/WristCode/Info.plist b/watch/WristCode/Info.plist index 56d5495..cf19714 100644 --- a/watch/WristCode/Info.plist +++ b/watch/WristCode/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + WKApplication WKWatchOnly diff --git a/watch/project.yml b/watch/project.yml index 553373b..c0509c4 100644 --- a/watch/project.yml +++ b/watch/project.yml @@ -11,6 +11,7 @@ settings: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete MARKETING_VERSION: "0.1.0" + # Default build number; scripts/testflight.sh overrides it with CURRENT_PROJECT_VERSION=. CURRENT_PROJECT_VERSION: "1" targets: WristCode: @@ -29,14 +30,37 @@ targets: # this and WKRunsIndependentlyOfCompanionApp, and requires WKCompanionAppBundleIdentifier # when WKRunsIndependentlyOfCompanionApp is set without WKWatchOnly. WKWatchOnly: true + # HTTPS only, so no export compliance documentation is needed. + ITSAppUsesNonExemptEncryption: false entitlements: path: WristCode/WristCode.entitlements properties: + # Archives are signed for development; exporting for App Store Connect re-signs with the + # distribution profile, which turns this into production. aps-environment: development settings: base: - PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode + # The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also + # the APNs topic. + PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's + # "Watch-only App" template creates. The stub owns the App Store Connect record + # (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone. + WristCodeContainer: + type: application.watchapp2-container + platform: iOS + deploymentTarget: "27.0" + dependencies: + - target: WristCode + settings: + base: + PRODUCT_NAME: WristCode + PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: WristCode + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO + TARGETED_DEVICE_FAMILY: "1" WristCodeTests: type: bundle.unit-test platform: watchOS @@ -55,6 +79,7 @@ schemes: build: targets: WristCode: all + WristCodeContainer: [archive] WristCodeTests: [test] run: config: Debug diff --git a/watch/scripts/testflight.sh b/watch/scripts/testflight.sh new file mode 100755 index 0000000..1ce8ddb --- /dev/null +++ b/watch/scripts/testflight.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Archive WristCode and upload it to App Store Connect for TestFlight. +# +# scripts/testflight.sh archive, then export and upload +# scripts/testflight.sh --no-upload archive, then export an .ipa to build/export without uploading +# +# BUILD_NUMBER defaults to a timestamp so every upload is unique. The App Store Connect API key comes +# from ASC_KEY_PATH, ASC_KEY_ID and ASC_ISSUER_ID. +set -euo pipefail + +upload=1 +for arg in "$@"; do + case "$arg" in + --no-upload) upload=0 ;; + *) echo "usage: $0 [--no-upload]" >&2; exit 2 ;; + esac +done + +cd "$(dirname "$0")/.." + +TEAM_ID="${TEAM_ID:-GQJNM5YK2M}" +ASC_KEY_ID="${ASC_KEY_ID:-864GCL5SDC}" +ASC_ISSUER_ID="${ASC_ISSUER_ID:-af37ae36-1f23-4245-b220-a7dc56d1a724}" +ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8}" +BUILD_NUMBER="${BUILD_NUMBER:-$(date +%Y%m%d%H%M)}" + +build_dir="build" +archive_path="$build_dir/WristCode-$BUILD_NUMBER.xcarchive" +export_path="$build_dir/export-$BUILD_NUMBER" +export_options="$build_dir/ExportOptions.plist" + +auth=( + -allowProvisioningUpdates + -authenticationKeyPath "$ASC_KEY_PATH" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" +) + +if [[ ! -f "$ASC_KEY_PATH" ]]; then + echo "App Store Connect API key not found at $ASC_KEY_PATH" >&2 + exit 1 +fi + +xcodegen generate +mkdir -p "$build_dir" + +echo "Archiving build $BUILD_NUMBER to $archive_path" +xcodebuild archive \ + -project WristCode.xcodeproj \ + -scheme WristCode \ + -destination 'generic/platform=watchOS' \ + -archivePath "$archive_path" \ + "${auth[@]}" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" + +if (( upload )); then destination=upload; else destination=export; fi +cat > "$export_options" < + + + + method + app-store-connect + destination + $destination + teamID + $TEAM_ID + signingStyle + automatic + uploadSymbols + + manageAppVersionAndBuildNumber + + + +PLIST + +echo "Exporting ($destination) to $export_path" +xcodebuild -exportArchive \ + -archivePath "$archive_path" \ + -exportPath "$export_path" \ + -exportOptionsPlist "$export_options" \ + "${auth[@]}" + +if (( upload )); then + echo "Uploaded build $BUILD_NUMBER. It appears in TestFlight once App Store Connect finishes processing." +else + echo "Exported build $BUILD_NUMBER to $export_path" +fi