Files
voice-cat/docs/apple-ios-release.md
T
Talon ea76d5157a
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
Improve iOS voice stability and user audio controls
2026-09-22 22:07:00 +02:00

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.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:

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:

  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:

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=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:

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.