From f6320dba6ff83ea4b467c819e713376251b04334 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 20 Jun 2026 22:58:42 +0800 Subject: [PATCH] build(ios): TestFlight release pipeline (xcodebuild + App Store Connect API key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lisa Pocket is native SwiftUI, not Expo, so Markup's `eas submit` doesn't apply; this is the equivalent — `xcodebuild archive` + `-exportArchive` (destination: upload) driven by an App Store Connect API key (automatic provisioning + the upload in one go). Mirrors the secret-gated, ephemeral-keychain style of release-mac-apps.yml (itself lifted from Markup). - packaging/ios-companion/testflight.sh — one-command local archive+upload; flips aps-environment to production for the archive; build number defaults to a timestamp. - .github/workflows/release-ios-testflight.yml — tag (pocket-v*) / dispatch; imports an Apple Distribution cert + the ASC .p8 from secrets and runs the script; no-op (stays green) when the secrets are absent. - RELEASE.md — the one-time Apple-account setup (app record + ASC API key) and the secrets list. gitignore the archive/export output. Inert without your Apple credentials (an ASC API key + an App Store Connect app record) — those are account actions only you can do. Verified here: bash + YAML + plist lint, simulator build still BUILD SUCCEEDED; the signed archive/upload can't run without the account, so it isn't exercised here. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-ios-testflight.yml | 100 +++++++++++++++++++ packaging/ios-companion/.gitignore | 3 + packaging/ios-companion/RELEASE.md | 73 ++++++++++++++ packaging/ios-companion/testflight.sh | 99 ++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 .github/workflows/release-ios-testflight.yml create mode 100644 packaging/ios-companion/RELEASE.md create mode 100755 packaging/ios-companion/testflight.sh diff --git a/.github/workflows/release-ios-testflight.yml b/.github/workflows/release-ios-testflight.yml new file mode 100644 index 0000000..0c1547f --- /dev/null +++ b/.github/workflows/release-ios-testflight.yml @@ -0,0 +1,100 @@ +name: Release — iOS TestFlight (Lisa Pocket) + +# Build Lisa Pocket (native SwiftUI, packaging/ios-companion) and upload it to +# TestFlight. The native-app analogue of Markup's EAS submit; modeled on the +# secret-gated, ephemeral-keychain pattern of release-mac-apps.yml. +# +# Runs automatically WHEN the App Store Connect API-key secrets are populated +# (gated on ASC_KEY_ID). When they're missing it's a no-op with a hint, so the +# repo stays green for contributors without Apple credentials. +# +# Required secrets (see packaging/ios-companion/RELEASE.md): +# ASC_KEY_ID App Store Connect API Key ID. +# ASC_ISSUER_ID API Key Issuer ID (UUID). +# ASC_API_KEY_BASE64 the AuthKey_.p8, base64-encoded: +# base64 -i AuthKey_XXXX.p8 | pbcopy +# IOS_DIST_CERT_BASE64 an "Apple Distribution" cert .p12 (cert + key), base64. +# IOS_DIST_CERT_PASSWORD passphrase set when exporting that .p12. +# APPLE_TEAM_ID 10-char team id (reused from the mac workflow). + +on: + push: + tags: + - "pocket-v*.*.*" + workflow_dispatch: + inputs: + build_number: + description: "Override build number (default: timestamp)" + required: false + +permissions: + contents: read + +jobs: + testflight: + runs-on: macos-latest + timeout-minutes: 45 + env: + # GitHub Actions forbids secrets.* in step-level `if:`, so promote the + # gate to a job-level env var. "true" iff the ASC API key is wired. + HAS_IOS_SIGNING: ${{ secrets.ASC_KEY_ID != '' }} + steps: + - uses: actions/checkout@v6 + + - name: Skip notice (no Apple credentials) + if: ${{ env.HAS_IOS_SIGNING != 'true' }} + run: echo "::notice::ASC_KEY_ID not set — skipping TestFlight upload. Populate the App Store Connect secrets to enable." + + - name: Install XcodeGen + if: ${{ env.HAS_IOS_SIGNING == 'true' }} + run: brew install xcodegen + + - name: Import Apple Distribution certificate + if: ${{ env.HAS_IOS_SIGNING == 'true' }} + env: + IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }} + IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/pocket-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" + echo "$IOS_DIST_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$RUNNER_TEMP/dist.p12" -P "$IOS_DIST_CERT_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security default-keychain -s "$KEYCHAIN_PATH" + rm -f "$RUNNER_TEMP/dist.p12" + echo "POCKET_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + - name: Write App Store Connect API key + if: ${{ env.HAS_IOS_SIGNING == 'true' }} + env: + ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/asc" + echo "$ASC_API_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/asc/AuthKey_${ASC_KEY_ID}.p8" + echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey_${ASC_KEY_ID}.p8" >> "$GITHUB_ENV" + + - name: Archive + upload to TestFlight + if: ${{ env.HAS_IOS_SIGNING == 'true' }} + working-directory: packaging/ios-companion + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + BUILD_NUMBER: ${{ github.event.inputs.build_number }} + run: ./testflight.sh + + - name: Clean up signing keychain + if: ${{ always() && env.POCKET_KEYCHAIN_PATH != '' }} + run: | + security delete-keychain "$POCKET_KEYCHAIN_PATH" || true + rm -f "$POCKET_KEYCHAIN_PATH" || true diff --git a/packaging/ios-companion/.gitignore b/packaging/ios-companion/.gitignore index 4936d09..235caa6 100644 --- a/packaging/ios-companion/.gitignore +++ b/packaging/ios-companion/.gitignore @@ -6,6 +6,9 @@ Sources/LisaPocket.entitlements Widgets/LisaPocketWidgets.entitlements # Build output / Xcode cruft. .build/ +build/ DerivedData/ +*.xcarchive +ExportOptions.plist *.xcuserstate .DS_Store diff --git a/packaging/ios-companion/RELEASE.md b/packaging/ios-companion/RELEASE.md new file mode 100644 index 0000000..0cfe762 --- /dev/null +++ b/packaging/ios-companion/RELEASE.md @@ -0,0 +1,73 @@ +# Shipping Lisa Pocket to TestFlight + +Lisa Pocket is a **native SwiftUI** app (XcodeGen), so it can't use Markup's +`eas build`/`eas submit` (that's Expo). The equivalent here is `xcodebuild +archive` + `-exportArchive (destination: upload)` driven by an **App Store +Connect API key**, which handles automatic provisioning *and* the TestFlight +upload. [`testflight.sh`](testflight.sh) runs it locally; the +[`release-ios-testflight.yml`](../../.github/workflows/release-ios-testflight.yml) +workflow runs the same thing in CI — modeled on the secret-gated pattern of +`release-mac-apps.yml` (itself lifted from Markup). + +App identity: bundle id **`ai.meetlisa.pocket`** (+ the widget extension +`ai.meetlisa.pocket.widgets`), team **`9LH9NBX7P4`**. + +## One-time setup (Apple account actions — only you can do these) + +1. **Register the app in App Store Connect.** App Store Connect → Apps → ➕ → + New App → iOS, bundle id `ai.meetlisa.pocket`, pick an SKU. (If the bundle + id isn't in the list, register it first under Certificates, IDs & Profiles → + Identifiers, with **App Groups** + **Push Notifications** capabilities; also + create the app group `group.ai.meetlisa.pocket`.) +2. **Create an App Store Connect API key.** Users and Access → Integrations → + App Store Connect API → ➕, role **App Manager**. Note the **Key ID** and + **Issuer ID**, and download `AuthKey_.p8` (downloadable once). +3. **First push permission only:** APNs alerts / Live-Activity refresh stay + inert until you also set `LISA_APNS_*` on the Mac running `lisa serve` + (see ../../packaging/ios-companion/README.md). TestFlight itself doesn't + need that — only live push delivery does. + +## Build + upload locally (one command) + +```sh +cd packaging/ios-companion +brew install xcodegen # one-time +ASC_KEY_ID=ABC123XYZ9 \ +ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ +ASC_KEY_PATH=~/Downloads/AuthKey_ABC123XYZ9.p8 \ +./testflight.sh +``` + +The Mac already has the **Apple Distribution** cert in its keychain, so locally +you only need the API key. The build number defaults to a timestamp; override +with `BUILD_NUMBER=…` / `MARKETING_VERSION=…` if needed. On Xcode < 16 pass +`EXPORT_METHOD=app-store`. + +Then: App Store Connect → your app → **TestFlight** → add yourself / a tester +group once Apple finishes processing (a few minutes). + +## Build + upload from CI (tag-triggered) + +Add these repo secrets (Settings → Secrets and variables → Actions): + +| Secret | What | +| --- | --- | +| `ASC_KEY_ID` | API Key ID | +| `ASC_ISSUER_ID` | API Issuer ID | +| `ASC_API_KEY_BASE64` | `base64 -i AuthKey_.p8 \| pbcopy` | +| `IOS_DIST_CERT_BASE64` | an *Apple Distribution* `.p12` (cert + key), base64 | +| `IOS_DIST_CERT_PASSWORD` | the `.p12` passphrase | +| `APPLE_TEAM_ID` | `9LH9NBX7P4` (reused from the mac workflow) | + +Then push a tag `pocket-vX.Y.Z` (or run the workflow manually). With the secrets +absent the workflow is a no-op, so the repo stays green for others. + +## Honest limits + +- The actual upload authenticates to **your** Apple account, so it can't run + without the API key + the app record above — those are account actions. +- TestFlight builds use the **production** APNs environment; `testflight.sh` + flips the generated `aps-environment` entitlement to `production` for the + archive (project.yml stays `development` for normal dev builds). +- Live push behavior is still only verifiable on a real device with `LISA_APNS_*` + configured; everything up to the upload is scripted here. diff --git a/packaging/ios-companion/testflight.sh b/packaging/ios-companion/testflight.sh new file mode 100755 index 0000000..ac594f0 --- /dev/null +++ b/packaging/ios-companion/testflight.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# testflight.sh — archive Lisa Pocket and upload it to TestFlight. +# +# The native-app analogue of Markup's EAS submit flow: Lisa Pocket is a native +# SwiftUI app (XcodeGen), not Expo, so instead of `eas build`/`eas submit` we +# drive `xcodebuild archive` + `-exportArchive` (destination: upload) with an +# App Store Connect API key, which handles automatic provisioning AND the +# upload in one go. Plain xcrun/xcodebuild, matching the secret-gated style of +# .github/workflows/release-mac-apps.yml (itself lifted from Markup). +# +# ── One-time setup (see RELEASE.md) ─────────────────────────────────────── +# 1. Apple Developer account; an "Apple Distribution" cert in the keychain +# (already present on the dev Mac; CI imports one from a secret). +# 2. Create the app in App Store Connect with bundle id ai.meetlisa.pocket. +# 3. Create an App Store Connect API key (Users and Access → Integrations → +# App Store Connect API), role "App Manager". Note the Key ID + Issuer ID +# and download the AuthKey_.p8 once. +# +# ── Required env ────────────────────────────────────────────────────────── +# ASC_KEY_ID App Store Connect API Key ID (e.g. ABC123XYZ9) +# ASC_ISSUER_ID API Key Issuer ID (a UUID) +# ASC_KEY_PATH path to the downloaded AuthKey_.p8 +# ── Optional env ────────────────────────────────────────────────────────── +# APPLE_TEAM_ID default 9LH9NBX7P4 +# MARKETING_VERSION default: whatever project.yml has +# BUILD_NUMBER default: date +%Y%m%d%H%M (must increase per version) +# EXPORT_METHOD default: app-store-connect (use "app-store" on Xcode<16) +# +# Usage: +# ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=~/AuthKey_….p8 ./testflight.sh +# --------------------------------------------------------------------------- +set -euo pipefail +cd "$(dirname "$0")" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" + +: "${ASC_KEY_ID:?set ASC_KEY_ID (App Store Connect API Key ID)}" +: "${ASC_ISSUER_ID:?set ASC_ISSUER_ID (API Key Issuer ID)}" +: "${ASC_KEY_PATH:?set ASC_KEY_PATH (path to AuthKey_.p8)}" +[ -f "$ASC_KEY_PATH" ] || { echo "✗ ASC_KEY_PATH not found: $ASC_KEY_PATH" >&2; exit 1; } +command -v xcodegen >/dev/null || { echo "✗ need xcodegen — brew install xcodegen" >&2; exit 1; } + +TEAM_ID="${APPLE_TEAM_ID:-9LH9NBX7P4}" +BUILD_NUMBER="${BUILD_NUMBER:-$(date +%Y%m%d%H%M)}" +EXPORT_METHOD="${EXPORT_METHOD:-app-store-connect}" +BUILD_DIR="build" +ARCHIVE="$BUILD_DIR/LisaPocket.xcarchive" + +echo "==> xcodegen generate" +xcodegen generate + +# TestFlight/App Store builds use the PRODUCTION APNs environment; project.yml +# stays "development" for normal dev builds, so flip the generated entitlement +# here (only for this archive). +if [ -f Sources/LisaPocket.entitlements ]; then + plutil -replace aps-environment -string production Sources/LisaPocket.entitlements +fi + +AUTH=(-allowProvisioningUpdates + -authenticationKeyPath "$ASC_KEY_PATH" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID") + +MV_ARG=() +[ -n "${MARKETING_VERSION:-}" ] && MV_ARG=(MARKETING_VERSION="$MARKETING_VERSION") + +echo "==> archive (build $BUILD_NUMBER · team $TEAM_ID)" +rm -rf "$ARCHIVE" +xcodebuild archive \ + -project LisaPocket.xcodeproj -scheme LisaPocket \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE" \ + "${AUTH[@]}" \ + CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM="$TEAM_ID" \ + CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=YES \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" "${MV_ARG[@]}" + +echo "==> write ExportOptions.plist (method=$EXPORT_METHOD, destination=upload)" +cat > "$BUILD_DIR/ExportOptions.plist" < + + + method$EXPORT_METHOD + destinationupload + teamID$TEAM_ID + signingStyleautomatic + uploadSymbols + +PLIST + +echo "==> export + upload to TestFlight" +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" \ + -exportPath "$BUILD_DIR/export" \ + "${AUTH[@]}" + +echo "✓ Uploaded to App Store Connect (build $BUILD_NUMBER)." +echo " It appears in TestFlight after Apple finishes processing (usually a few minutes)."