248 lines
10 KiB
Markdown
248 lines
10 KiB
Markdown
# Releasing the iOS app to TestFlight
|
|
|
|
VoiceCat's supported iOS client is the .NET 10 UIKit app in
|
|
`clients/apple/VoiceCat.iOS`. Its ReplayKit upload extension remains a separate Swift/Xcode
|
|
target in `native/apple/broadcast`. A release must sign the host and extension separately, with
|
|
matching versions and the shared App Group entitlement.
|
|
|
|
This guide produces an App Store Connect distribution-signed IPA. Uploading it is a separate
|
|
step that performs Apple's server-side validation.
|
|
|
|
## Stable identifiers
|
|
|
|
Do not change these when replacing an earlier Swift build with the managed client:
|
|
|
|
| Component | Identifier |
|
|
|---|---|
|
|
| Apple Developer team | `FJV8L966W4` |
|
|
| Host app | `me.iamtalon.voicecat` |
|
|
| ReplayKit extension | `me.iamtalon.voicecat.broadcast` |
|
|
| Shared App Group | `group.me.iamtalon.voicecat` |
|
|
|
|
Provisioning-profile UUIDs are disposable and may change. TestFlight continuity depends on the
|
|
team, host bundle ID, marketing version, and a build number greater than every build already
|
|
uploaded for that marketing version. The profile UUID does not identify the App Store app.
|
|
|
|
## Secrets and credentials
|
|
|
|
An App Store Connect `.p8` API key is not a code-signing key and is not needed to build the IPA.
|
|
It is only an alternative way to authenticate automated uploads. Keep the `.p8`, Key ID, and
|
|
Issuer ID private. This workflow uses the distribution private key stored locally in the macOS
|
|
login Keychain and can upload through Transporter without exposing API credentials.
|
|
|
|
## Prerequisites
|
|
|
|
- The macOS, Xcode, .NET SDK, and iOS workload versions documented in `CLAUDE.md`.
|
|
- An Apple Distribution certificate with its private key in the login Keychain.
|
|
- Two current **App Store Connect** distribution provisioning profiles: one for the host and
|
|
one for the ReplayKit extension.
|
|
- Both App IDs associated with `group.me.iamtalon.voicecat` in Certificates, Identifiers &
|
|
Profiles.
|
|
|
|
Create or inspect certificates in **Xcode > Settings > Accounts > Manage Certificates**. Verify
|
|
the resulting local identity from an ordinary Terminal session:
|
|
|
|
```bash
|
|
security find-identity -v -p codesigning
|
|
```
|
|
|
|
The output must include an `Apple Distribution` identity for team `FJV8L966W4`. A certificate
|
|
visible in the developer portal is insufficient if its private key is not in the Keychain.
|
|
|
|
## Create the provisioning profiles
|
|
|
|
In [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/profiles/list),
|
|
create two profiles using **Distribution > App Store Connect**:
|
|
|
|
1. `me.iamtalon.voicecat`, with a descriptive name such as `VoiceCatProfile`.
|
|
2. `me.iamtalon.voicecat.broadcast`, with a name such as `VoiceCatBroadcastProfile`.
|
|
|
|
Select the current Apple Distribution certificate for both. Download the `.mobileprovision`
|
|
files and keep them in a known directory. Opening a profile in Xcode can fail silently when an
|
|
older Xcode-managed profile is cached, so verify the installed result rather than assuming the
|
|
double-click worked.
|
|
|
|
Each profile must have:
|
|
|
|
- the expected explicit application identifier;
|
|
- `group.me.iamtalon.voicecat` in `com.apple.security.application-groups`;
|
|
- `get-task-allow=false`;
|
|
- the same public-certificate fingerprint as the usable local distribution identity.
|
|
|
|
If Xcode does not install a downloaded profile, decode it, read its UUID, and install it under
|
|
that UUID without deleting older profiles:
|
|
|
|
```bash
|
|
profile="$HOME/Downloads/VoiceCatProfile.mobileprovision"
|
|
decoded="$(mktemp /tmp/voicecat-profile.XXXXXX.plist)"
|
|
openssl cms -verify -inform DER -in "$profile" -noverify -out "$decoded"
|
|
uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$decoded")"
|
|
mkdir -p "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
|
install -m 0644 "$profile" \
|
|
"$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/$uuid.mobileprovision"
|
|
rm "$decoded"
|
|
```
|
|
|
|
Repeat for the extension profile. Profile names passed to the build are their internal `Name`
|
|
values, not necessarily their filenames.
|
|
|
|
## Build the distribution app
|
|
|
|
Choose a monotonically increasing numeric build number. Keep the host and extension marketing
|
|
and build versions identical.
|
|
|
|
```bash
|
|
export VOICECAT_BUILD_NUMBER=2026092101
|
|
export VOICECAT_DISPLAY_VERSION=0.0.1
|
|
export VOICECAT_DEVELOPMENT_TEAM=FJV8L966W4
|
|
|
|
export VOICECAT_CODESIGN_KEY='Apple Distribution: Your Name (FJV8L966W4)'
|
|
export VOICECAT_CODESIGN_PROVISION='VoiceCatProfile'
|
|
|
|
export VOICECAT_BROADCAST_CODESIGN_KEY="$VOICECAT_CODESIGN_KEY"
|
|
export VOICECAT_BROADCAST_CODESIGN_PROVISION='VoiceCatBroadcastProfile'
|
|
|
|
clients/apple/build-ios-device.sh \
|
|
--configuration Release \
|
|
--output dist/ios-appstore
|
|
```
|
|
|
|
The host is built by .NET and the extension by Xcode. The explicit extension variables prevent
|
|
Xcode from silently selecting a development certificate/profile during a Release build.
|
|
|
|
The build output is `dist/ios-appstore/VoiceCat.iOS.app`. Release trimming can report the known
|
|
aggregate `IL2104` warning for Google.Protobuf; other warnings and all errors remain release
|
|
blockers.
|
|
|
|
### Version metadata and incremental builds
|
|
|
|
The host manifest deliberately omits `CFBundleShortVersionString` and `CFBundleVersion` so the
|
|
.NET `ApplicationDisplayVersion` and `ApplicationVersion` properties own those values. The
|
|
extension manifest expands Xcode's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`.
|
|
|
|
After changing this version plumbing or either source manifest, stale iOS intermediates can
|
|
retain an earlier generated `Info.plist`. Clean the iOS project and rebuild if the packaged
|
|
values do not match the requested values:
|
|
|
|
```bash
|
|
dotnet clean clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj \
|
|
-c Release -r ios-arm64 \
|
|
-p:NuGetLockFilePath=obj/packages.device.lock.json
|
|
```
|
|
|
|
Closing Finder windows that display the output directory can help if cleanup appears to linger.
|
|
|
|
## Validate the app locally
|
|
|
|
```bash
|
|
app=dist/ios-appstore/VoiceCat.iOS.app
|
|
extension="$app/PlugIns/VoiceCatBroadcast.appex"
|
|
|
|
codesign --verify --deep --strict --verbose=4 "$app"
|
|
codesign -dvvv "$app"
|
|
codesign -dvvv "$extension"
|
|
codesign -d --entitlements :- "$app"
|
|
codesign -d --entitlements :- "$extension"
|
|
|
|
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \
|
|
-c 'Print :CFBundleShortVersionString' \
|
|
-c 'Print :CFBundleVersion' "$app/Info.plist"
|
|
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \
|
|
-c 'Print :CFBundleShortVersionString' \
|
|
-c 'Print :CFBundleVersion' "$extension/Info.plist"
|
|
|
|
lipo -archs "$app/VoiceCat.iOS"
|
|
lipo -archs "$extension/VoiceCatBroadcast"
|
|
```
|
|
|
|
Before packaging, confirm:
|
|
|
|
- both authorities are the intended Apple Distribution identity;
|
|
- both team identifiers are `FJV8L966W4`;
|
|
- both entitlements contain the shared App Group;
|
|
- both have `get-task-allow=false` and `beta-reports-active=true`;
|
|
- the bundle identifiers are the stable identifiers above;
|
|
- both version pairs match; and
|
|
- both executables contain `arm64`.
|
|
|
|
Also decode each embedded profile and verify its name, UUID, application identifier, and
|
|
`get-task-allow` value:
|
|
|
|
```bash
|
|
for profile in "$app/embedded.mobileprovision" \
|
|
"$extension/embedded.mobileprovision"; do
|
|
decoded="$(mktemp /tmp/voicecat-embedded.XXXXXX.plist)"
|
|
openssl cms -verify -inform DER -in "$profile" -noverify -out "$decoded"
|
|
/usr/libexec/PlistBuddy \
|
|
-c 'Print :Name' \
|
|
-c 'Print :UUID' \
|
|
-c 'Print :Entitlements:application-identifier' \
|
|
-c 'Print :Entitlements:get-task-allow' "$decoded"
|
|
rm "$decoded"
|
|
done
|
|
```
|
|
|
|
## Package the IPA
|
|
|
|
An IPA is a ZIP archive whose top-level `Payload` directory contains the signed app. Copying the
|
|
already-signed bundle with `ditto` preserves extended attributes and nested signatures.
|
|
|
|
```bash
|
|
package_root="$(mktemp -d /tmp/voicecat-ipa.XXXXXX)"
|
|
mkdir "$package_root/Payload"
|
|
ditto dist/ios-appstore/VoiceCat.iOS.app \
|
|
"$package_root/Payload/VoiceCat.app"
|
|
ditto -c -k --sequesterRsrc --keepParent "$package_root/Payload" \
|
|
"dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa"
|
|
unzip -t "dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa"
|
|
shasum -a 256 \
|
|
"dist/VoiceCat-${VOICECAT_DISPLAY_VERSION}-${VOICECAT_BUILD_NUMBER}.ipa"
|
|
```
|
|
|
|
## Upload to TestFlight
|
|
|
|
Open Apple's Transporter app, sign in, drag the IPA into it, and choose **Deliver**. Transporter
|
|
performs the server-side validation that local `codesign` cannot. Uploading is intentionally not
|
|
part of the build script because it changes external state and requires App Store Connect
|
|
authentication.
|
|
|
|
After processing completes in App Store Connect, inspect any warnings, complete export-compliance
|
|
questions, assign the build to an internal testing group, and run the physical-device release
|
|
gates listed in `PROGRESS.md`.
|
|
|
|
## Troubleshooting signatures
|
|
|
|
### No signing identities match the profile
|
|
|
|
The profile was probably generated for an older distribution certificate. Creating a new
|
|
certificate does not update existing profiles. Regenerate both App Store Connect profiles with
|
|
the new certificate, download them, verify their certificate fingerprints, and install them by
|
|
UUID.
|
|
|
|
### `security find-identity` returns zero identities
|
|
|
|
Keychain access can be hidden by a sandboxed shell. Retry from ordinary Terminal before changing
|
|
certificates. If Terminal also reports zero, confirm that the certificate has an associated
|
|
private key in Keychain Access; a portal certificate or cloud-managed certificate alone cannot
|
|
sign locally.
|
|
|
|
### Automatic provisioning cannot refresh profiles
|
|
|
|
Xcode command-line builds can report a missing `Xcode-Token` even when the GUI displays an Apple
|
|
account. Manual App Store Connect profiles avoid that dependency and make the two-target signing
|
|
inputs explicit.
|
|
|
|
### App and extension versions differ
|
|
|
|
Apple validates nested bundle metadata. Confirm the host does not hard-code version keys, the
|
|
extension uses the Xcode version placeholders, both release environment variables are set, and a
|
|
clean build regenerated the app manifests.
|
|
|
|
## Last verified release build
|
|
|
|
On 2026-09-21, version `0.0.1`, build `2026092101` was built with .NET 10.0.401 and Xcode 27.0.
|
|
The host and ReplayKit extension passed strict nested-signature validation with App Store Connect
|
|
profiles, matching distribution identities, matching versions, the shared App Group, and
|
|
`get-task-allow=false`. The resulting IPA was packaged locally; Apple server-side upload
|
|
validation remains a separate gate.
|