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 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 10:08:55 -05:00
1 parent 16bae4410d
commit f772f4e4c4
4 files changed
+141 -4

No files matched your search

+24 -3
View File
@@ -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-<n> 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 <udid> org.gbrown.wristcode -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode -demo -screen new # new thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
```
`screenshots/` holds the screens captured this way.
+2
View File
@@ -20,6 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>WKApplication</key>
<true/>
<key>WKWatchOnly</key>
+26 -1
View File
@@ -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=<n>.
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
+89
View File
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!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>destination</key>
<string>$destination</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
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