10 KiB
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.voicecatin Certificates, Identifiers & Profiles.
Create or inspect certificates in Xcode > Settings > Accounts > Manage Certificates. Verify the resulting local identity from an ordinary Terminal session:
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, create two profiles using Distribution > App Store Connect:
me.iamtalon.voicecat, with a descriptive name such asVoiceCatProfile.me.iamtalon.voicecat.broadcast, with a name such asVoiceCatBroadcastProfile.
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.voicecatincom.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:
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.
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:
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
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=falseandbeta-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:
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.
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-22, version 0.0.1, build 2026092202 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.