Skip to content

iOS TestFlight

iOS TestFlight #220

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