From 6ea9550499b6e188b532534b0adb6a41e36b99f8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 29 Jul 2026 18:50:35 -0300 Subject: [PATCH 1/2] ci: build and upload the phone app to TestFlight Adds a manual workflow that builds the companion app, signs it for the App Store, checks it will actually work, and uploads it. Manual on purpose. Every green build of the Mac app ships itself, but a TestFlight upload burns a build number that can never be used again and lands on real testers' phones, so it should be someone deciding rather than a side effect of merging. The check before uploading matters more than it sounds. The first build was signed with a profile that had no push permission at all, which still installs, still runs, and silently never receives anything. That looks like the app being broken rather than the build being wrong, so the script now refuses to upload unless push, iCloud and the build type all read correctly on the signed app. --- .github/workflows/ios-testflight.yml | 103 ++++++++++++ scripts/build-ios-testflight.sh | 237 +++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 .github/workflows/ios-testflight.yml create mode 100755 scripts/build-ios-testflight.sh 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/scripts/build-ios-testflight.sh b/scripts/build-ios-testflight.sh new file mode 100755 index 00000000..62a0ab01 --- /dev/null +++ b/scripts/build-ios-testflight.sh @@ -0,0 +1,237 @@ +#!/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) + +# -------------------------------------------------------------------- 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." From 37e93de36a6d93bb7d80dc4cd3c4995bd41c7287 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 29 Jul 2026 19:02:03 -0300 Subject: [PATCH 2/2] fix: declare which way up the phone app can be held App Store validation rejects the app outright because it says it works on iPad but never says which orientations it supports. iPad needs all four for split screen; the phone gets everything except upside down, which is what Apple's own apps do. This one is easy to miss. Building locally only prints a warning, so it looks fine right up until the upload is rejected. It cost two attempts, one of which had already registered the app, so the CI script now checks for it before starting a build rather than after burning an upload. --- ios/ProgramaSpike/project.yml | 21 +++++++++++++++++++++ scripts/build-ios-testflight.sh | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) 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 index 62a0ab01..bde249f6 100755 --- a/scripts/build-ios-testflight.sh +++ b/scripts/build-ios-testflight.sh @@ -123,6 +123,27 @@ if ! command -v xcodegen >/dev/null 2>&1; then 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" \