# 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; - both executables contain `arm64`; and - the host `Info.plist` contains `CFBundleIconName` and the bundle contains both `AppIcon60x60@2x.png` (120x120) and `AppIcon76x76@2x~ipad.png` (152x152). 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. ### Missing app icon or `CFBundleIconName` App Store Connect rejects uploads with errors 90022, 90023, and 90713 when the host bundle has no `CFBundleIconName`. Local `codesign` cannot catch this; only upload validation does. The asset catalog alone is not enough. `Info.plist` must name it: ```xml XSAppIconAssetsAssets.xcassets/AppIcon.appiconset ``` This is an **app-manifest key**, not an MSBuild property. The `ReadAppManifest` task reads `XSAppIconAssets` out of `Info.plist` and passes `--app-icon` to `actool`; setting a csproj property of the same name has no effect. The build strips the key from the shipped manifest. Without it, `actool` still compiles the catalog but emits an empty `obj////actool/partial-info.plist`, so no icon keys reach the app. Verify the build inputs rather than the bundle, since stale intermediates can leave icon PNGs from an earlier build in place while the manifest has no icon keys: ```bash cat clients/apple/VoiceCat.iOS/obj/Release/net10.0-ios27.0/ios-arm64/actool/partial-info.plist ``` A correct build writes `CFBundleIcons` and `CFBundleIcons~ipad` there. A single 1024x1024 universal entry in `AppIcon.appiconset` is sufficient; `actool` derives the 120x120 and 152x152 variants. ### 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 `2026092203` 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 host carries `CFBundleIconName` with the 120x120 and 152x152 icons. The resulting IPA was packaged locally; Apple server-side upload validation remains a separate gate.