diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 00000000..1c5e25e4 --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,103 @@ +name: iOS TestFlight + +# Manual only, deliberately. Unlike the macOS app — where every green CI run on +# main auto-ships — a TestFlight upload consumes a build number that can never be +# reused and pushes to real testers' devices. That should be a decision, not a +# side effect of merging. +on: + workflow_dispatch: + inputs: + upload: + description: "Upload to TestFlight (false = build and verify only)" + type: boolean + default: false + +concurrency: + # Build numbers must be monotonic and are derived from the run id, so two + # concurrent runs would race for the same slot in App Store Connect. + group: ios-testflight + cancel-in-progress: false + +jobs: + build: + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + # No submodules: the iOS app depends on vendor/CmuxIrohTransport, which is + # vendored in-tree, and on iroh-ffi via SwiftPM. It does not need the + # ghostty submodule, and skipping it saves several minutes. + + - name: Select Xcode + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)" + if [ -z "$XCODE_APP" ]; then XCODE_APP="/Applications/Xcode.app"; fi + sudo xcode-select -s "$XCODE_APP/Contents/Developer" + echo "DEVELOPER_DIR=$XCODE_APP/Contents/Developer" >> "$GITHUB_ENV" + echo "Selected: $XCODE_APP" + + - name: Install xcodegen + run: brew install xcodegen + + - name: Materialise signing assets + env: + IOS_DIST_CERT_BASE64: ${{ secrets.APPLE_IOS_DIST_CERT_BASE64 }} + IOS_APP_PROFILE_BASE64: ${{ secrets.APPLE_IOS_APP_PROFILE_BASE64 }} + IOS_WIDGET_PROFILE_BASE64: ${{ secrets.APPLE_IOS_WIDGET_PROFILE_BASE64 }} + ASC_KEY_P8_BASE64: ${{ secrets.APPSTORE_CONNECT_KEY_P8_BASE64 }} + run: | + set -euo pipefail + missing="" + for v in IOS_DIST_CERT_BASE64 IOS_APP_PROFILE_BASE64 IOS_WIDGET_PROFILE_BASE64; do + [ -z "${!v}" ] && missing="$missing $v" + done + if [ -n "$missing" ]; then + echo "Missing required secret(s):$missing" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/signing" + echo "$IOS_DIST_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/dist.p12" + echo "$IOS_APP_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/app.mobileprovision" + echo "$IOS_WIDGET_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/widget.mobileprovision" + if [ -n "$ASC_KEY_P8_BASE64" ]; then + echo "$ASC_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/asc.p8" + fi + + - name: Build, sign and verify + env: + PROGRAMA_IOS_DIST_CERT_P12: ${{ runner.temp }}/signing/dist.p12 + PROGRAMA_IOS_DIST_CERT_PASSWORD: ${{ secrets.APPLE_IOS_DIST_CERT_PASSWORD }} + PROGRAMA_IOS_APP_PROFILE: ${{ runner.temp }}/signing/app.mobileprovision + PROGRAMA_IOS_WIDGET_PROFILE: ${{ runner.temp }}/signing/widget.mobileprovision + PROGRAMA_IOS_TEAM_ID: ZNHHMX2RP6 + PROGRAMA_ASC_KEY_ID: ${{ secrets.APPSTORE_CONNECT_KEY_ID }} + PROGRAMA_ASC_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_ISSUER_ID }} + PROGRAMA_ASC_KEY_P8: ${{ runner.temp }}/signing/asc.p8 + PROGRAMA_IOS_UPLOAD: ${{ inputs.upload && '1' || '0' }} + run: | + set -euo pipefail + # Monotonic and unique per run, matching the macOS release lane. App Store + # Connect rejects a reused CFBundleVersion, and the committed value in + # project.yml is only a local-dev default. + RUN_ATTEMPT="$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")" + export PROGRAMA_IOS_BUILD_NUMBER="${GITHUB_RUN_ID}${RUN_ATTEMPT}" + echo "Build number: $PROGRAMA_IOS_BUILD_NUMBER" + chmod +x scripts/build-ios-testflight.sh + ./scripts/build-ios-testflight.sh + + - name: Upload ipa artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: programa-ios-${{ github.run_id }} + path: .ios-build/export/*.ipa + if-no-files-found: warn + retention-days: 14 + + - name: Clean up signing material + if: always() + run: | + rm -rf "$RUNNER_TEMP/signing" "$HOME/private_keys" || true + security delete-keychain ios-build.keychain >/dev/null 2>&1 || true diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml index e026663d..b3f8bf02 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -53,6 +53,27 @@ targets: properties: CFBundleDisplayName: "Programa" UILaunchScreen: {} + # Required, not optional: TARGETED_DEVICE_FAMILY is "1,2" so the app + # claims iPad support, and App Store validation HARD FAILS an iPad-capable + # bundle that declares no orientations ("Invalid bundle. No orientations + # were specified"). It surfaces during archive only as a warning + # ("All interface orientations must be supported unless the app requires + # full screen"), so it is easy to ship past locally and only discover at + # upload. + # + # iPad gets all four because that is what iPad multitasking requires. + # iPhone omits upside-down, which is conventional and what Apple's own + # apps do. If this ever becomes iPhone-only, drop TARGETED_DEVICE_FAMILY + # to "1" and the ~ipad variant with it. + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight # Export-compliance declaration required by App Store Connect. The app # uses only standard QUIC/TLS (via iroh) and Apple's own CloudKit -- # no proprietary or non-standard cryptography -- which is the exempt diff --git a/scripts/build-ios-testflight.sh b/scripts/build-ios-testflight.sh new file mode 100755 index 00000000..bde249f6 --- /dev/null +++ b/scripts/build-ios-testflight.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds, signs and (optionally) uploads the iOS companion app to TestFlight. +# +# Signing is MANUAL here, unlike a local Xcode archive. CI has no authenticated +# Xcode account, so `-allowProvisioningUpdates` cannot create or refresh anything; +# the distribution certificate and both App Store profiles must arrive as +# secrets. That is also why this script derives the profile names from the +# imported profiles at runtime rather than hardcoding them: profile names change +# whenever they are regenerated in the portal, and a stale hardcoded name fails +# the export with an unhelpful error. +# +# Required environment: +# PROGRAMA_IOS_DIST_CERT_P12 path to the Apple Distribution .p12 +# PROGRAMA_IOS_DIST_CERT_PASSWORD its password +# PROGRAMA_IOS_APP_PROFILE path to the app's App Store .mobileprovision +# PROGRAMA_IOS_WIDGET_PROFILE path to the widget's App Store .mobileprovision +# PROGRAMA_IOS_TEAM_ID e.g. ZNHHMX2RP6 +# Optional: +# PROGRAMA_IOS_BUILD_NUMBER CFBundleVersion to stamp (defaults to 1) +# PROGRAMA_IOS_UPLOAD set to 1 to upload; otherwise export only +# PROGRAMA_ASC_KEY_ID / PROGRAMA_ASC_ISSUER_ID / PROGRAMA_ASC_KEY_P8 +# App Store Connect API key, required to upload + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +IOS_DIR="$ROOT_DIR/ios/ProgramaSpike" +WORK_DIR="${PROGRAMA_IOS_WORK_DIR:-$ROOT_DIR/.ios-build}" +ARCHIVE_PATH="$WORK_DIR/ProgramaSpike.xcarchive" +EXPORT_DIR="$WORK_DIR/export" + +APP_BUNDLE_ID="com.darkroom.programa.spike" +WIDGET_BUNDLE_ID="com.darkroom.programa.spike.widgets" + +require() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing required environment variable: $name" >&2 + exit 1 + fi +} + +require PROGRAMA_IOS_DIST_CERT_P12 +require PROGRAMA_IOS_DIST_CERT_PASSWORD +require PROGRAMA_IOS_APP_PROFILE +require PROGRAMA_IOS_WIDGET_PROFILE +require PROGRAMA_IOS_TEAM_ID + +BUILD_NUMBER="${PROGRAMA_IOS_BUILD_NUMBER:-1}" + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" "$EXPORT_DIR" + +# ---------------------------------------------------------------- keychain +KEYCHAIN="ios-build.keychain" +KEYCHAIN_PASSWORD="$(uuidgen)" +security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +security import "$PROGRAMA_IOS_DIST_CERT_P12" -k "$KEYCHAIN" \ + -P "$PROGRAMA_IOS_DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null +security list-keychains -d user -s "$KEYCHAIN" + +SIGN_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | grep -oE '"Apple Distribution: [^"]+"' | head -1 | tr -d '"')" +if [[ -z "$SIGN_IDENTITY" ]]; then + echo "No 'Apple Distribution' identity found in the imported certificate." >&2 + echo "A Developer ID or Apple Development cert cannot sign for TestFlight." >&2 + security find-identity -v -p codesigning "$KEYCHAIN" >&2 || true + exit 1 +fi +echo "Signing identity: $SIGN_IDENTITY" + +# ------------------------------------------------------- provisioning profiles +# Xcode looks for profiles by UUID filename in this directory (moved here in +# Xcode 16; the old ~/Library/MobileDevice path is no longer consulted). +PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" +mkdir -p "$PROFILE_DIR" + +profile_field() { + security cms -D -i "$1" 2>/dev/null \ + | /usr/libexec/PlistBuddy -c "Print $2" /dev/stdin 2>/dev/null +} + +install_profile() { + local src="$1" + local uuid name + uuid="$(profile_field "$src" ":UUID")" + name="$(profile_field "$src" ":Name")" + if [[ -z "$uuid" || -z "$name" ]]; then + echo "Could not read UUID/Name from profile: $src" >&2 + exit 1 + fi + cp "$src" "$PROFILE_DIR/$uuid.mobileprovision" + echo "$name" +} + +APP_PROFILE_NAME="$(install_profile "$PROGRAMA_IOS_APP_PROFILE")" +WIDGET_PROFILE_NAME="$(install_profile "$PROGRAMA_IOS_WIDGET_PROFILE")" +echo "App profile: $APP_PROFILE_NAME" +echo "Widget profile: $WIDGET_PROFILE_NAME" + +# Fail early and loudly if the app profile does not grant production push. A +# TestFlight build without it installs and runs fine and silently receives no +# CloudKit pushes, which reads as "the companion app is broken" rather than as a +# signing problem. This exact gap was found by hand on the first build: the App +# Store profile predated Push Notifications being enabled on the App ID. +APP_PROFILE_APS="$(profile_field "$PROGRAMA_IOS_APP_PROFILE" ":Entitlements:aps-environment")" +if [[ "$APP_PROFILE_APS" != "production" ]]; then + echo "" >&2 + echo "FAIL: the app's App Store profile has aps-environment='${APP_PROFILE_APS:-}'," >&2 + echo "not 'production'. The build would receive no push notifications." >&2 + echo "Regenerate the profile with Push Notifications enabled on App ID $APP_BUNDLE_ID." >&2 + exit 1 +fi + +# ------------------------------------------------------------------- generate +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen is required (brew install xcodegen)" >&2 + exit 1 +fi +(cd "$IOS_DIR" && xcodegen generate) + +# Fail in seconds rather than after a ~10 minute archive and a consumed upload +# slot. An iPad-capable bundle (TARGETED_DEVICE_FAMILY "1,2") with no declared +# orientations passes the build with only a warning and then HARD FAILS App Store +# validation: "Invalid bundle. No orientations were specified". That cost two +# round trips on the first manual upload, including one that had already created +# the App Store Connect record. +SOURCE_PLIST="$IOS_DIR/ProgramaSpike/Info.plist" +DEVICE_FAMILY="$(grep -m1 'TARGETED_DEVICE_FAMILY' "$IOS_DIR/project.yml" | sed 's/.*"\(.*\)".*/\1/')" +if [[ "$DEVICE_FAMILY" == *"2"* ]]; then + for key in UISupportedInterfaceOrientations "UISupportedInterfaceOrientations~ipad"; do + if ! /usr/libexec/PlistBuddy -c "Print :$key" "$SOURCE_PLIST" >/dev/null 2>&1; then + echo "FAIL: $SOURCE_PLIST declares no :$key, but TARGETED_DEVICE_FAMILY is" >&2 + echo "'$DEVICE_FAMILY' (iPad-capable). App Store validation rejects that bundle." >&2 + echo "Declare it in project.yml under the app target's info.properties, or drop" >&2 + echo "TARGETED_DEVICE_FAMILY to \"1\" if the app should be iPhone-only." >&2 + exit 1 + fi + done + echo "Orientations declared for both iPhone and iPad." +fi + +# -------------------------------------------------------------------- archive +xcodebuild \ + -project "$IOS_DIR/ProgramaSpike.xcodeproj" \ + -scheme ProgramaSpike \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE_PATH" \ + -clonedSourcePackagesDirPath "$WORK_DIR/source-packages" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + DEVELOPMENT_TEAM="$PROGRAMA_IOS_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + archive + +# --------------------------------------------------------------------- export +cat > "$WORK_DIR/ExportOptions.plist" < + + + + method + app-store-connect + teamID + $PROGRAMA_IOS_TEAM_ID + signingStyle + manual + signingCertificate + $SIGN_IDENTITY + provisioningProfiles + + $APP_BUNDLE_ID + $APP_PROFILE_NAME + $WIDGET_BUNDLE_ID + $WIDGET_PROFILE_NAME + + destination + export + uploadSymbols + + compileBitcode + + + +PLIST + +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportOptionsPlist "$WORK_DIR/ExportOptions.plist" \ + -exportPath "$EXPORT_DIR" + +IPA_PATH="$(ls "$EXPORT_DIR"/*.ipa 2>/dev/null | head -1)" +if [[ -z "$IPA_PATH" ]]; then + echo "Export produced no .ipa" >&2 + exit 1 +fi +echo "Exported: $IPA_PATH" + +# ------------------------------------------------------ verify before upload +# Check the SIGNED entitlements, not the profile. The profile is what was +# requested; the signature is what the device enforces, and they can differ. +VERIFY_DIR="$WORK_DIR/verify" +rm -rf "$VERIFY_DIR"; mkdir -p "$VERIFY_DIR" +(cd "$VERIFY_DIR" && unzip -oq "$IPA_PATH") +SIGNED_APP="$VERIFY_DIR/Payload/ProgramaSpike.app" + +SIGNED_ENTS="$(codesign -d --entitlements - --xml "$SIGNED_APP" 2>/dev/null || true)" +check_entitlement() { + local key="$1" expected="$2" + local actual + actual="$(printf '%s' "$SIGNED_ENTS" | plutil -extract "$key" raw -o - - 2>/dev/null || echo "")" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: signed entitlement $key is '$actual', expected '$expected'" >&2 + return 1 + fi + echo " ok $key = $actual" +} + +echo "Verifying signed entitlements:" +verify_failed=0 +check_entitlement "aps-environment" "production" || verify_failed=1 +check_entitlement "get-task-allow" "false" || verify_failed=1 +check_entitlement "com.apple.developer.icloud-container-environment" "Production" || verify_failed=1 +if (( verify_failed )); then + echo "Refusing to upload a build that would not behave correctly in TestFlight." >&2 + exit 1 +fi + +echo "IPA_PATH=$IPA_PATH" + +# --------------------------------------------------------------------- upload +if [[ "${PROGRAMA_IOS_UPLOAD:-0}" != "1" ]]; then + echo "PROGRAMA_IOS_UPLOAD is not 1; export only, not uploading." + exit 0 +fi + +require PROGRAMA_ASC_KEY_ID +require PROGRAMA_ASC_ISSUER_ID +require PROGRAMA_ASC_KEY_P8 + +# altool finds the key by convention: ./private_keys/AuthKey_.p8 relative +# to one of a fixed set of search paths. +KEY_DIR="$HOME/private_keys" +mkdir -p "$KEY_DIR" +cp "$PROGRAMA_ASC_KEY_P8" "$KEY_DIR/AuthKey_${PROGRAMA_ASC_KEY_ID}.p8" + +xcrun altool --upload-app \ + --type ios \ + --file "$IPA_PATH" \ + --apiKey "$PROGRAMA_ASC_KEY_ID" \ + --apiIssuer "$PROGRAMA_ASC_ISSUER_ID" + +echo "Uploaded to App Store Connect. Processing takes a few minutes before it appears in TestFlight."