Add signed iOS release workflow
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

This commit is contained in:
2026-09-21 04:05:40 +02:00
parent cf808483e0
commit 35617e9708
9 changed files with 308 additions and 8 deletions
+2 -1
View File
@@ -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
+8 -1
View File
@@ -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`.
-2
View File
@@ -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
+3 -1
View File
@@ -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"
+247
View File
@@ -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.
+2 -2
View File
@@ -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()
{