iOS TestFlight #216
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: iOS TestFlight | |
| # Builds and signs the iOS app on a GitHub-hosted macOS runner - no local Mac | |
| # required. Signing is MANUAL and deterministic: one Apple Distribution | |
| # certificate (.p12) plus two App Store provisioning profiles (the app | |
| # com.minded.app and the widget com.minded.app.widget) are imported from GitHub | |
| # secrets into a throwaway keychain each run. We deliberately do NOT use | |
| # -allowProvisioningUpdates: on ephemeral runners it mints a brand-new signing | |
| # certificate every run and never reuses it, so the account fills up and archives | |
| # start failing with Apple's per-team certificate cap. The API key below is still | |
| # used, but only to upload the finished build to TestFlight (altool), not to sign. | |
| # One-time setup (cert + profiles + secrets) is in RELEASING.md, "iOS / TestFlight". | |
| # | |
| # Triggers (see the `if` on the "Upload to TestFlight" step for what uploads): | |
| # - push to main → build + signed archive ONLY, no upload. iOS is the | |
| # can't-test-locally variant, so this is the early-warning | |
| # signal that catches silent compile/signing breakage. | |
| # - nightly cron → full build + TestFlight upload, but ONLY when main has | |
| # moved since the last upload (see the `freshness` job). | |
| # - vX.Y.Z tag → full build + upload, alongside the store releases. | |
| # - manual dispatch → full build + upload, on demand. | |
| # TestFlight is *not* a public release: builds go to your testers only. | |
| # Promoting to the App Store is a separate manual step in App Store Connect. | |
| # | |
| # One-time setup and the testing flow are documented in RELEASING.md. | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| # Nightly TestFlight upload at 04:00 UTC. Batches the day's commits into one | |
| # beta instead of uploading per push; quiet days upload nothing at all. | |
| - cron: '0 4 * * *' | |
| push: | |
| branches: [main] # verify-only - the upload step is skipped for these | |
| tags: ['v[0-9]+.[0-9]+.[0-9]+'] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ios-testflight | |
| cancel-in-progress: false | |
| env: | |
| NODE_VERSION: '22' | |
| # The app record's bundle id in App Store Connect (matches project.pbxproj). | |
| IOS_BUNDLE_ID: com.minded.app | |
| IOS_TEAM_ID: '363FAFK383' | |
| # Provisioning-profile NAMES exactly as created in the Apple Developer portal | |
| # (App Store distribution profiles). Single source of truth: consumed both by | |
| # the widget-wiring step (baked per-target into the pbxproj) and by the export | |
| # step. If you rename a profile in the portal, change it here to match. | |
| IOS_APP_PROFILE_NAME: 'minded profile main' | |
| IOS_WIDGET_PROFILE_NAME: 'minded profile widget' | |
| jobs: | |
| # Cheap ubuntu gate in front of the (10x-billed, ~30min) macOS job: don't | |
| # rebuild a commit TestFlight already has. Without this the nightly cron | |
| # shipped a new build number for byte-identical code on every quiet day - | |
| # testers get an "update" that is the code they're already running, and the | |
| # build-number history stops meaning anything. | |
| # | |
| # "Already has" = the head SHA of the most recent *successful* run that would | |
| # have uploaded. Plain pushes to main are verify-only, so they're excluded; | |
| # everything else (cron, dispatch, tag) uploads. A previously skipped nightly | |
| # carries the same SHA it skipped on, so the comparison stays stable; a | |
| # *failed* nightly isn't successful, so a fixed build still gets retried. | |
| freshness: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| outputs: | |
| fresh: ${{ steps.check.outputs.fresh }} | |
| steps: | |
| - name: Has main moved since the last upload? | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Deliberately strict: if the API call below fails, fail the run rather | |
| # than defaulting to "build". A silent fallback would quietly restore | |
| # the every-night-same-build behaviour this job exists to stop. | |
| set -euo pipefail | |
| # Only the cron fires on its own schedule; a human (or a tag) asking | |
| # for a build always gets one, even if the commit is unchanged. | |
| if [ "$GITHUB_EVENT_NAME" != schedule ]; then | |
| echo "fresh=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| LAST=$(gh api \ | |
| "repos/$GITHUB_REPOSITORY/actions/workflows/ios-testflight.yml/runs?status=success&per_page=100" \ | |
| --jq '[.workflow_runs[] | select(.event != "push" or .head_branch != "main")][0].head_sha // ""') | |
| # No prior upload in the run-retention window -> build; better one | |
| # redundant beta than a silently stalled pipeline. | |
| if [ -n "$LAST" ] && [ "$LAST" = "$GITHUB_SHA" ]; then | |
| echo "fresh=false" >> "$GITHUB_OUTPUT" | |
| echo "main is still at \`$GITHUB_SHA\`, already on TestFlight - skipping tonight's build." \ | |
| >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "fresh=true" >> "$GITHUB_OUTPUT" | |
| echo "New commits since \`${LAST:-none}\` - building \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| testflight: | |
| needs: freshness | |
| if: needs.freshness.outputs.fresh == 'true' | |
| runs-on: macos-26 | |
| environment: production | |
| timeout-minutes: 40 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: extension/package-lock.json | |
| - name: Build iOS web assets | |
| working-directory: extension | |
| run: | | |
| npm ci | |
| # Build the SolidJS app for iOS -> extension/distIOS | |
| npx vite build --mode ios --emptyOutDir | |
| # Capacitor's webDir points at the Android bundle path, which is | |
| # gitignored and therefore absent on a clean checkout. Stage the | |
| # freshly-built iOS bundle there so `cap sync` copies the correct | |
| # assets into the iOS app and (re)generates the native | |
| # capacitor.config.json + plugin list. | |
| rm -rf ../android/app/src/main/assets/web | |
| mkdir -p ../android/app/src/main/assets/web | |
| cp -R distIOS/. ../android/app/src/main/assets/web/ | |
| npx cap sync ios | |
| - name: Wire in the widget extension target | |
| working-directory: extension/ios/App | |
| run: | | |
| # The MindedWidget Swift sources live in the repo, but the WidgetKit | |
| # app-extension target is added to App.xcodeproj programmatically (no | |
| # Mac/Xcode GUI needed). The script is idempotent. Run it before the | |
| # `pod install` below so CocoaPods integrates with the widget target | |
| # already in place; the archive step is the real verification - a | |
| # malformed project fails CI here, not silently at upload. | |
| # CocoaPods already vendors the xcodeproj gem on the runner, so only | |
| # install it if the script's `require` can't find it - this avoids a | |
| # version bump and any non-sudo gem-install permission issue. | |
| ruby -e "require 'xcodeproj'" 2>/dev/null || gem install xcodeproj --no-document | |
| ruby scripts/add_widget_target.rb | |
| - name: Install CocoaPods | |
| working-directory: extension/ios/App | |
| run: pod install | |
| - name: Stage App Store Connect API key | |
| env: | |
| ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} | |
| ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY_BASE64 }} | |
| run: | | |
| # Only the TestFlight upload (altool) uses this key now - signing is | |
| # manual (see the import step below). altool discovers the key by KEY_ID | |
| # under ~/.appstoreconnect/private_keys; the $RUNNER_TEMP copy just gives | |
| # the cleanup step a stable path to remove. | |
| KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8" | |
| printf '%s' "$ASC_KEY_BASE64" | base64 -d > "$KEY_PATH" | |
| mkdir -p "$HOME/.appstoreconnect/private_keys" | |
| cp "$KEY_PATH" "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" | |
| echo "ASC_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" | |
| - name: Import signing certificate and profiles | |
| env: | |
| DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} | |
| DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} | |
| APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }} | |
| WIDGET_PROFILE_BASE64: ${{ secrets.IOS_WIDGET_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| set -euo pipefail | |
| # Import the one persisted Apple Distribution identity into a throwaway | |
| # keychain unlocked only for this job. The keychain password is | |
| # ephemeral (generated here, never leaves the runner) - the real secret | |
| # is the .p12, protected by DIST_CERT_PASSWORD. | |
| KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" | |
| KEYCHAIN_PW="$(openssl rand -base64 24)" | |
| CERT_PATH="$RUNNER_TEMP/dist.p12" | |
| printf '%s' "$DIST_CERT_P12_BASE64" | base64 -d > "$CERT_PATH" | |
| security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" | |
| # Stop the keychain auto-locking part-way through a long archive. | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" | |
| security import "$CERT_PATH" -P "$DIST_CERT_PASSWORD" \ | |
| -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" | |
| # Let codesign use the imported key without an interactive UI prompt. | |
| security set-key-partition-list -S apple-tool:,apple: \ | |
| -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" >/dev/null | |
| # Prepend our keychain to the search list, KEEPING the login/system | |
| # keychains so the Apple WWDR intermediate stays resolvable for chaining. | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" \ | |
| $(security list-keychains -d user | sed 's/["]//g') | |
| # Install both App Store profiles under their embedded UUID (how Xcode | |
| # indexes them), in both locations Xcode may read from. | |
| install_profile() { | |
| local tmp="$RUNNER_TEMP/profile.mobileprovision" | |
| printf '%s' "$1" | base64 -d > "$tmp" | |
| local uuid | |
| uuid="$(security cms -D -i "$tmp" | plutil -extract UUID raw -o - -)" | |
| for dir in \ | |
| "$HOME/Library/MobileDevice/Provisioning Profiles" \ | |
| "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do | |
| mkdir -p "$dir" | |
| cp "$tmp" "$dir/$uuid.mobileprovision" | |
| done | |
| rm -f "$tmp" | |
| } | |
| install_profile "$APP_PROFILE_BASE64" | |
| install_profile "$WIDGET_PROFILE_BASE64" | |
| rm -f "$CERT_PATH" | |
| echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" | |
| - name: Archive | |
| run: | | |
| # Marketing version tracks package.json; build number tracks the run | |
| # number so every upload is strictly higher than the last (TestFlight | |
| # rejects duplicate build numbers). Signing is manual - the per-target | |
| # profile specifiers were baked into the project by the widget-wiring | |
| # step, and the identity + profiles are in the keychain imported above. | |
| MARKETING_VERSION=$(node -p "require('./extension/package.json').version") | |
| xcodebuild archive \ | |
| -workspace extension/ios/App/App.xcworkspace \ | |
| -scheme App \ | |
| -configuration Release \ | |
| -destination 'generic/platform=iOS' \ | |
| -archivePath "$RUNNER_TEMP/App.xcarchive" \ | |
| MARKETING_VERSION="$MARKETING_VERSION" \ | |
| CURRENT_PROJECT_VERSION="${{ github.run_number }}" \ | |
| DEVELOPMENT_TEAM="$IOS_TEAM_ID" | |
| - name: Export .ipa | |
| run: | | |
| set -euo pipefail | |
| # Generated here (not committed) so the profile NAMES have a single | |
| # source of truth - the IOS_*_PROFILE_NAME env above. Manual signing: | |
| # each bundle id maps to its App Store profile; no API key needed. | |
| EXPORT_OPTS="$RUNNER_TEMP/ExportOptions.plist" | |
| cat > "$EXPORT_OPTS" <<PLIST | |
| <?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> | |
| <key>method</key><string>app-store</string> | |
| <key>teamID</key><string>${IOS_TEAM_ID}</string> | |
| <key>signingStyle</key><string>manual</string> | |
| <key>signingCertificate</key><string>Apple Distribution</string> | |
| <key>provisioningProfiles</key> | |
| <dict> | |
| <key>${IOS_BUNDLE_ID}</key><string>${IOS_APP_PROFILE_NAME}</string> | |
| <key>${IOS_BUNDLE_ID}.widget</key><string>${IOS_WIDGET_PROFILE_NAME}</string> | |
| </dict> | |
| <key>destination</key><string>export</string> | |
| <key>uploadSymbols</key><true/> | |
| <key>stripSwiftSymbols</key><true/> | |
| </dict> | |
| </plist> | |
| PLIST | |
| xcodebuild -exportArchive \ | |
| -archivePath "$RUNNER_TEMP/App.xcarchive" \ | |
| -exportPath "$RUNNER_TEMP/export" \ | |
| -exportOptionsPlist "$EXPORT_OPTS" | |
| - name: Upload .ipa as workflow artifact | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: minded-ios-${{ github.run_number }} | |
| path: ${{ runner.temp }}/export/*.ipa | |
| retention-days: 14 | |
| if-no-files-found: ignore | |
| - name: Upload to TestFlight | |
| # Plain pushes to main are verification builds - everything above runs | |
| # (so a broken archive/sign still fails CI), but we don't distribute. | |
| # Upload only on tags, manual dispatch, and the nightly cron. | |
| if: github.event_name != 'push' || startsWith(github.ref, 'refs/tags/') | |
| env: | |
| ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| run: | | |
| set -euo pipefail | |
| IPA=$(ls "$RUNNER_TEMP/export/"*.ipa 2>/dev/null | head -n1 || true) | |
| if [ -z "$IPA" ] || [ ! -f "$IPA" ]; then | |
| echo "::error::Export produced no .ipa - nothing to upload to TestFlight." | |
| exit 1 | |
| fi | |
| # `altool --upload-app` is deprecated but functional on Xcode 16. If a | |
| # future Xcode removes it, switch to ExportOptions `destination: upload` | |
| # (xcodebuild uploads directly using the same -authenticationKey* flags) | |
| # or `altool --upload-package`. | |
| xcrun altool --upload-app \ | |
| --type ios \ | |
| --file "$IPA" \ | |
| --apiKey "$ASC_KEY_ID" \ | |
| --apiIssuer "$ASC_ISSUER_ID" | |
| - name: Cleanup signing material | |
| if: always() | |
| run: | | |
| rm -f "$ASC_KEY_PATH" "$HOME/.appstoreconnect/private_keys/"AuthKey_*.p8 || true | |
| security delete-keychain "${KEYCHAIN_PATH:-}" 2>/dev/null || true | |
| rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision \ | |
| "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.mobileprovision \ | |
| 2>/dev/null || true |