Add signed iOS release workflow
This commit is contained in:
+2
-1
@@ -27,7 +27,8 @@ The source-of-truth layout is:
|
||||
- Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
|
||||
ScreenCaptureKit paths on devices. Verify distinct left/right capture with the stereo microphone
|
||||
preset, including changing mono/stereo while joined, and Windows desktop/per-app stereo sharing.
|
||||
- Complete Developer ID signing/notarization and iOS distribution signing.
|
||||
- Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been
|
||||
distribution-signed and packaged locally; upload the IPA for Apple's server-side validation.
|
||||
- Run the published Linux container and a 30-minute-or-longer server soak.
|
||||
|
||||
## Working rule
|
||||
|
||||
@@ -26,4 +26,11 @@ ReplayKit extension require signing profiles with App Group `group.me.iamtalon.v
|
||||
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
|
||||
changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio.
|
||||
|
||||
The shared ring contract is documented in `docs/broadcast-ring-format.md`.
|
||||
App Store builds use the same device builder with `--configuration Release`. Set
|
||||
`VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host
|
||||
`VOICECAT_CODESIGN_KEY`/`VOICECAT_CODESIGN_PROVISION` pair, and the extension
|
||||
`VOICECAT_BROADCAST_CODESIGN_KEY`/`VOICECAT_BROADCAST_CODESIGN_PROVISION` pair. The host and
|
||||
extension profiles must both be App Store Connect profiles and include the shared App Group.
|
||||
|
||||
The complete TestFlight workflow is documented in `docs/apple-ios-release.md`. The shared ring
|
||||
contract is documented in `docs/broadcast-ring-format.md`.
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleDisplayName</key><string>VoiceCat</string>
|
||||
<key>CFBundleIdentifier</key><string>me.iamtalon.voicecat</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.0.1</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key><true/>
|
||||
<key>NSMicrophoneUsageDescription</key><string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key><array><string>audio</string></array>
|
||||
|
||||
@@ -10,8 +10,19 @@ project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj
|
||||
|
||||
mkdir -p "$output"
|
||||
signing=()
|
||||
versioning=(MARKETING_VERSION="${VOICECAT_DISPLAY_VERSION:-0.0.1}")
|
||||
if [[ -n "${VOICECAT_BUILD_NUMBER:-}" ]]; then
|
||||
versioning+=(CURRENT_PROJECT_VERSION="$VOICECAT_BUILD_NUMBER")
|
||||
fi
|
||||
if [[ "$sdk" == "iphonesimulator" ]]; then
|
||||
signing+=(CODE_SIGNING_ALLOWED=NO)
|
||||
elif [[ -n "${VOICECAT_BROADCAST_CODESIGN_KEY:-}" && -n "${VOICECAT_BROADCAST_CODESIGN_PROVISION:-}" ]]; then
|
||||
signing+=(
|
||||
DEVELOPMENT_TEAM="${VOICECAT_DEVELOPMENT_TEAM:?VOICECAT_DEVELOPMENT_TEAM is required for distribution signing}"
|
||||
CODE_SIGN_STYLE=Manual
|
||||
CODE_SIGN_IDENTITY="$VOICECAT_BROADCAST_CODESIGN_KEY"
|
||||
PROVISIONING_PROFILE_SPECIFIER="$VOICECAT_BROADCAST_CODESIGN_PROVISION"
|
||||
)
|
||||
elif [[ -n "${VOICECAT_DEVELOPMENT_TEAM:-}" ]]; then
|
||||
signing+=(DEVELOPMENT_TEAM="$VOICECAT_DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Automatic)
|
||||
if [[ "${VOICECAT_ALLOW_PROVISIONING_UPDATES:-}" == "1" ]]; then
|
||||
@@ -26,6 +37,7 @@ xcodebuild \
|
||||
-arch "$architecture" \
|
||||
-derivedDataPath "$output/derived" \
|
||||
CONFIGURATION_BUILD_DIR="$output" \
|
||||
"${versioning[@]}" \
|
||||
"${signing[@]}" \
|
||||
build
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ while [ "$#" -gt 0 ]; do
|
||||
--output) output="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
echo "usage: $0 [--configuration Debug|Release] [--output DIR]"
|
||||
echo "optional env: VOICECAT_DOTNET, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION"
|
||||
echo "optional env: VOICECAT_DOTNET, VOICECAT_BUILD_NUMBER, VOICECAT_DISPLAY_VERSION, VOICECAT_CODESIGN_KEY, VOICECAT_CODESIGN_PROVISION"
|
||||
echo "distribution extension env: VOICECAT_DEVELOPMENT_TEAM, VOICECAT_BROADCAST_CODESIGN_KEY, VOICECAT_BROADCAST_CODESIGN_PROVISION"
|
||||
exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -29,6 +30,7 @@ set -- build "$project" -c "$configuration" -r ios-arm64 --no-restore -m:1 \
|
||||
-p:NuGetLockFilePath="$device_lock"
|
||||
if [ -n "${VOICECAT_CODESIGN_KEY:-}" ]; then set -- "$@" -p:CodesignKey="$VOICECAT_CODESIGN_KEY"; fi
|
||||
if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvision="$VOICECAT_CODESIGN_PROVISION"; fi
|
||||
if [ -n "${VOICECAT_BUILD_NUMBER:-}" ]; then set -- "$@" -p:ApplicationVersion="$VOICECAT_BUILD_NUMBER"; fi
|
||||
"$dotnet_host" "$@"
|
||||
|
||||
app="$root/clients/apple/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# 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.
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?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/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -53,6 +53,34 @@ public class PublishServerScriptTests
|
||||
Assert.DoesNotContain("<TreatWarningsAsErrors>false</TreatWarningsAsErrors>", project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IosBroadcastExtensionPreservesSharedAppGroupAndSupportsDistributionSigning()
|
||||
{
|
||||
string root = FindRoot();
|
||||
string entitlements = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "native", "apple", "broadcast", "VoiceCatBroadcast.entitlements"));
|
||||
string script = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "clients", "apple", "VoiceCat.iOS", "build-broadcast-extension.sh"));
|
||||
|
||||
Assert.Contains("com.apple.security.application-groups", entitlements);
|
||||
Assert.Contains("group.me.iamtalon.voicecat", entitlements);
|
||||
Assert.Contains("CODE_SIGN_STYLE=Manual", script);
|
||||
Assert.Contains("PROVISIONING_PROFILE_SPECIFIER=\"$VOICECAT_BROADCAST_CODESIGN_PROVISION\"", script);
|
||||
Assert.Contains("CURRENT_PROJECT_VERSION=\"$VOICECAT_BUILD_NUMBER\"", script);
|
||||
Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script);
|
||||
|
||||
string deviceScript = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "clients", "apple", "build-ios-device.sh"));
|
||||
Assert.Contains("-p:ApplicationVersion=\"$VOICECAT_BUILD_NUMBER\"", deviceScript);
|
||||
|
||||
string appManifest = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
|
||||
string extensionManifest = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "native", "apple", "broadcast", "Info.plist"));
|
||||
Assert.DoesNotContain("<key>CFBundleVersion</key>", appManifest);
|
||||
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", extensionManifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user