CI: iOS TestFlight build workflow (signed + upload via altool)

Phase 2 of iOS CI. Full signed pipeline that:

1) Decodes secrets:
   - APPLE_DIST_CERT_P12_BASE64 + password → temp keychain
   - APPLE_PROVISIONING_PROFILE_BASE64 → ~/Library/MobileDevice/...
   - APPLE_API_KEY_P8_BASE64 → ~/.appstoreconnect/private_keys/
2) Parses the .mobileprovision for its UUID + Name (used in ExportOptions).
3) Generates ios/ExportOptions.plist dynamically with the right Team ID +
   profile name + 'app-store' method.
4) Runs flutter build ipa --release --export-options-plist=...
5) Uploads the .ipa to TestFlight via xcrun altool with the API key.
6) Always cleans up keychain + temp credentials, even on failure.

Trigger: workflow_dispatch ONLY (manual). TestFlight uploads consume a
build number with every push — we don't want auto-trigger spamming
TestFlight. Bump the build number in scripts/ios/app_env.sh before
each TestFlight push (or wire a workflow_dispatch input later).

All Apple secrets (and the Trocador ones reused from Linux/Android) are
already configured in Gitea Actions.

If signing fails on first run: most likely the profile name extracted
from the .mobileprovision doesn't match what Xcode expects, OR the
distribution cert in the .p12 doesn't match what the profile pins. The
keychain identity sanity-check + ExportOptions.plist echo in the logs
will tell us which.
This commit is contained in:
jwinterm
2026-05-18 21:23:54 -04:00
parent 6304521e92
commit c7f64f8273
+266
View File
@@ -0,0 +1,266 @@
name: Hash Wallet iOS TestFlight build
# Phase 2 of iOS CI: full signed build that uploads to TestFlight.
# Phase 1 (build-ios-sim.yml) validates the Mac runner with no signing —
# run that first if TestFlight blows up unexpectedly.
#
# Required Gitea Actions secrets (all account/app credentials):
# APPLE_TEAM_ID — 10-char (D8PL9F7X33)
# APPLE_KEY_ID — 10-char (CHK6B69G58)
# APPLE_ISSUER_ID — UUID
# APPLE_API_KEY_P8_BASE64 — base64 of AuthKey_<KeyID>.p8
# APPLE_DIST_CERT_P12_BASE64 — base64 of distribution.p12
# APPLE_DIST_CERT_P12_PASSWORD — password set during openssl pkcs12 export
# APPLE_PROVISIONING_PROFILE_BASE64 — base64 of Hash_Wallet.mobileprovision
# APPLE_KEYCHAIN_PASSWORD — any string, used to create the temp keychain
# TROCADOR_* — reused from Linux/Android
on:
# Don't auto-trigger on every push — TestFlight uploads are slow and use
# build numbers. Manual trigger only; later we can add a workflow_dispatch
# input for build number or auto-bump.
workflow_dispatch:
defaults:
run:
shell: bash
jobs:
build:
runs-on: macos-latest
env:
APP_IOS_TYPE: cakewallet
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show toolchain
run: |
set -x
flutter --version
xcodebuild -version
pod --version
uname -m
# ---- External prebuilt deps -----------------------------------------
- name: Fetch prebuilt torch_dart
run: |
set -x -e
pushd scripts
rm -rf torch_dart torch_dart.tar.gz
wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz
mkdir torch_dart
tar -xzf torch_dart.tar.gz -C torch_dart
rm torch_dart.tar.gz
popd
- name: Fetch prebuilt reown_flutter
run: |
set -x -e
pushd scripts
rm -rf reown_flutter reown_flutter.tar.gz
wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz
mkdir reown_flutter
tar -xzf reown_flutter.tar.gz -C reown_flutter
rm reown_flutter.tar.gz
popd
- name: Clone BitBox Flutter
run: |
set -x -e
pushd scripts
./build_bitbox_flutter.sh
popd
- name: Fetch prebuilt monero_c bundle
run: |
set -x -e
./scripts/prepare_moneroc.sh
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
mkdir -p "scripts/monero_c/release/$MONERO_C_TAG"
pushd "scripts/monero_c/release/$MONERO_C_TAG"
wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip
unzip -q release-bundle.zip
rm release-bundle.zip
popd
# ---- Configure: pubspec.yaml, Info.plist, etc. ----------------------
- name: Run iOS configure (cakewallet profile)
run: |
set -x -e
pushd scripts/ios
source ./app_env.sh cakewallet
./app_config.sh
popd
# ---- Secrets ---------------------------------------------------------
- name: Generate per-module secrets.g.dart files (empty defaults)
run: dart run tool/generate_new_secrets.dart
- name: Inject Trocador affiliate secrets
env:
TROCADOR_API_KEY: ${{ secrets.TROCADOR_API_KEY }}
TROCADOR_MONERO_API_KEY: ${{ secrets.TROCADOR_MONERO_API_KEY }}
TROCADOR_EXCHANGE_MARKUP: ${{ secrets.TROCADOR_EXCHANGE_MARKUP }}
run: |
sed -i '' \
-e "s|const trocadorApiKey = '';|const trocadorApiKey = '${TROCADOR_API_KEY}';|" \
-e "s|const trocadorMoneroApiKey = '';|const trocadorMoneroApiKey = '${TROCADOR_MONERO_API_KEY}';|" \
-e "s|const trocadorExchangeMarkup = '';|const trocadorExchangeMarkup = '${TROCADOR_EXCHANGE_MARKUP:-1}';|" \
lib/.secrets.g.dart
# ---- Apple signing setup --------------------------------------------
- name: Create temp keychain + import distribution cert
env:
APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }}
APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }}
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
run: |
set -e
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
# Create + unlock dedicated keychain so we don't touch the user's login keychain
security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" # 6 hours
security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Add to user search list so codesign can find it
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g')
# Import the .p12
P12="$RUNNER_TEMP/dist.p12"
echo "$APPLE_DIST_CERT_P12_BASE64" | base64 -d > "$P12"
security import "$P12" -k "$KEYCHAIN_PATH" -P "$APPLE_DIST_CERT_P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Sanity-check: list identities the keychain offers
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
rm -f "$P12"
- name: Install provisioning profile + parse name/UUID for ExportOptions
env:
APPLE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
run: |
set -e
PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_DIR"
PROFILE="$RUNNER_TEMP/Hash_Wallet.mobileprovision"
echo "$APPLE_PROVISIONING_PROFILE_BASE64" | base64 -d > "$PROFILE"
# Parse the .mobileprovision to get its UUID + Name (CMS-decoded plist).
PROFILE_UUID=$(security cms -D -i "$PROFILE" | plutil -extract UUID raw -o - -)
PROFILE_NAME=$(security cms -D -i "$PROFILE" | plutil -extract Name raw -o - -)
echo "Profile UUID: $PROFILE_UUID"
echo "Profile name: $PROFILE_NAME"
echo "PROFILE_UUID=$PROFILE_UUID" >> "$GITHUB_ENV"
echo "PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV"
# Xcode looks profiles up by UUID filename.
cp "$PROFILE" "$PROFILES_DIR/$PROFILE_UUID.mobileprovision"
ls -la "$PROFILES_DIR" | head -10
- name: Install App Store Connect API key for altool
env:
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
run: |
set -e
mkdir -p "$HOME/.appstoreconnect/private_keys"
echo "$APPLE_API_KEY_P8_BASE64" | base64 -d > "$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_KEY_ID}.p8"
ls -la "$HOME/.appstoreconnect/private_keys/"
# ---- Flutter init + codegen + pod install ---------------------------
- name: Initialize Flutter SDK (iOS precache)
run: |
flutter --version
flutter precache --ios --no-linux --no-android --no-macos --no-windows --no-fuchsia --no-web || true
- name: Build generated code
run: bash model_generator.sh
- name: Generate localization
run: dart run tool/generate_localization.dart
- name: pod install
run: |
cd ios
pod install --repo-update
# ---- Generate ExportOptions.plist + build IPA -----------------------
- name: Write ExportOptions.plist
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
cat > ios/ExportOptions.plist <<EOF
<?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>${APPLE_TEAM_ID}</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.suchsoftware.hashwallet</key>
<string>${PROFILE_NAME}</string>
</dict>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
EOF
cat ios/ExportOptions.plist
- name: Build IPA
run: |
set -x -e
flutter build ipa --release \
--dart-define-from-file=env.json \
--export-options-plist=ios/ExportOptions.plist
ls -la build/ios/ipa/
- name: Upload IPA artifact
uses: actions/upload-artifact@v3
with:
name: hash-wallet-ios-${{ github.sha }}
path: build/ios/ipa/*.ipa
retention-days: 30
# ---- Upload to TestFlight -------------------------------------------
- name: Upload to TestFlight via altool
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
run: |
set -e
IPA=$(ls build/ios/ipa/*.ipa | head -1)
echo "Uploading $IPA to TestFlight..."
xcrun altool --upload-app \
--type ios \
--file "$IPA" \
--apiKey "$APPLE_KEY_ID" \
--apiIssuer "$APPLE_ISSUER_ID"
# ---- Cleanup keychain so we don't leak it across builds -------------
- name: Cleanup
if: always()
env:
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
run: |
set +e
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null
rm -rf "$HOME/.appstoreconnect/private_keys"
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles"/*.mobileprovision
true