name: Hash Bags 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_.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: concurrency: group: ios-testflight-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: build: runs-on: macos-latest env: APP_IOS_TYPE: cakewallet BRANCH_NAME: ${{ github.head_ref || github.ref_name }} # CocoaPods needs UTF-8; Gitea runner shell defaults to ASCII-8BIT. LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 # See note in build-ios-sim.yml — fallback if /etc/hosts trick # isn't in place. Safe no-op once it is. NODE_TLS_REJECT_UNAUTHORIZED: '0' steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Check Flutter 3.32.0 id: flutter_check run: | if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.32\.0"; then echo "installed=true" >> "$GITHUB_OUTPUT" flutter --version else echo "installed=false" >> "$GITHUB_OUTPUT" fi - name: Install Flutter 3.32.0 (if missing) if: steps.flutter_check.outputs.installed != 'true' uses: subosito/flutter-action@v2 with: flutter-version: '3.32.0' channel: stable cache: true - name: Install CocoaPods (if missing) run: | if command -v pod >/dev/null; then pod --version exit 0 fi if command -v brew >/dev/null; then brew install cocoapods else export GEM_HOME="$HOME/.gem" export PATH="$GEM_HOME/bin:$PATH" echo "GEM_HOME=$HOME/.gem" >> "$GITHUB_ENV" echo "$HOME/.gem/bin" >> "$GITHUB_PATH" gem install --user-install cocoapods --no-document fi pod --version - name: Install Rust + iOS targets (if missing) run: | if ! command -v rustup >/dev/null; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --no-modify-path echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" export PATH="$HOME/.cargo/bin:$PATH" fi rm -rf "$HOME/.rustup/downloads"/*.partial 2>/dev/null || true # Stable + iOS targets (most plugins use these) rustup target add aarch64-apple-ios rustup target add aarch64-apple-ios-sim rustup target add x86_64-apple-ios # Nightly + iOS targets — at least one plugin (sp_scanner / payjoin # / something in the cargokit chain) builds its rust component # with +nightly. The TestFlight archive bombs at "Missing manifest # in toolchain 'nightly-aarch64-apple-darwin'" if nightly isn't # installed alongside stable. rustup toolchain install nightly --profile minimal rustup target add aarch64-apple-ios --toolchain nightly rustup target add aarch64-apple-ios-sim --toolchain nightly rustup target add x86_64-apple-ios --toolchain nightly rustup show - name: Install Go + gomobile (if missing) run: | if ! command -v go >/dev/null; then if command -v brew >/dev/null; then brew install go else echo "Go missing and brew not available"; exit 1 fi fi go version GOPATH=$(go env GOPATH) echo "$GOPATH/bin" >> "$GITHUB_PATH" export PATH="$PATH:$GOPATH/bin" if ! command -v gomobile >/dev/null; then go install golang.org/x/mobile/cmd/gomobile@latest go install golang.org/x/mobile/cmd/gobind@latest fi which gomobile && gomobile version || true - name: Show toolchain run: | set -x flutter --version xcodebuild -version pod --version uname -m # ---- External prebuilt deps ----------------------------------------- # macOS doesn't ship wget; using curl everywhere. - name: Fetch prebuilt torch_dart run: | set -x -e pushd scripts rm -rf torch_dart torch_dart.tar.gz curl -fsSL -o torch_dart.tar.gz https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz mkdir torch_dart tar -xzf torch_dart.tar.gz -C torch_dart rm torch_dart.tar.gz popd # LibTorch.xcframework ships with a lie: each slice's Info.plist # claims MinimumOSVersion=12.0, but the Mach-O LC_BUILD_VERSION # in the binaries says 13.0 (device) and 14.0 (simulator). # Apple's TestFlight validator catches this mismatch and rejects # the upload with ITMS-90208. Patch the Info.plists to match # the actual binary build versions. DEVICE_PLIST=scripts/torch_dart/ios/LibTorch.xcframework/ios-arm64/LibTorch.framework/Info.plist SIM_PLIST=scripts/torch_dart/ios/LibTorch.xcframework/ios-arm64-simulator/LibTorch.framework/Info.plist for entry in "$DEVICE_PLIST:13.0" "$SIM_PLIST:14.0"; do plist="${entry%:*}" min="${entry#*:}" if [[ -f "$plist" ]]; then plutil -replace MinimumOSVersion -string "$min" "$plist" echo "patched $plist -> MinimumOSVersion $min" plutil -p "$plist" | grep -E "MinimumOSVersion|CFBundleName" || true else echo "WARNING: $plist not found" fi done - name: Fetch prebuilt reown_flutter run: | set -x -e pushd scripts rm -rf reown_flutter reown_flutter.tar.gz curl -fsSL -o reown_flutter.tar.gz https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz mkdir reown_flutter tar -xzf reown_flutter.tar.gz -C reown_flutter rm reown_flutter.tar.gz popd - name: Clone BitBox Flutter (iOS — skip Android bindings) run: | # See note in build-ios-sim.yml — iOS uses bitbox's native plugin, # not the .aar that build_bindings.sh generates. set -x -e pushd scripts if [[ ! -d bitbox_flutter ]]; then git clone https://github.com/konstantinullrich/bitbox_flutter fi cd bitbox_flutter git fetch -a git reset --hard git checkout 5a6e6dd388ef64003f86094af80d5453518b601d git reset --hard 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" curl -fsSL -O 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 - name: Build MoneroWallet + WowneroWallet XCFrameworks run: | set -x -e pushd scripts/ios bash ./gen_framework.sh popd ls -la ios/MoneroWallet.xcframework ios/WowneroWallet.xcframework # ---- 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 + WalletConnect + MoonPay 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 }} WALLETCONNECT_PROJECT_ID: ${{ secrets.WALLETCONNECT_PROJECT_ID }} # MoonPay: publishable key only. NEVER inject moonPaySecretKey (server-side). MOONPAY_API_KEY: ${{ secrets.MOONPAY_API_KEY }} EXCHANGE_HELPER_API_KEY: ${{ secrets.EXCHANGE_HELPER_API_KEY }} 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}';|" \ -e "s|const walletConnectProjectId = '';|const walletConnectProjectId = '${WALLETCONNECT_PROJECT_ID}';|" \ -e "s|const moonPayApiKey = '';|const moonPayApiKey = '${MOONPAY_API_KEY}';|" \ -e "s|const exchangeHelperApiKey = '';|const exchangeHelperApiKey = '${EXCHANGE_HELPER_API_KEY}';|" \ lib/.secrets.g.dart # ---- Apple signing setup -------------------------------------------- - name: Sanity-check Apple secrets are reaching the runner env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }} APPLE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }} APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} run: | # Print LENGTHS only — never the secret values. Length 0 means the # secret isn't actually reaching the runner (most often: secret is # set on a different Gitea repo than this workflow is running in). set +e echo "APPLE_TEAM_ID length: ${#APPLE_TEAM_ID}" echo "APPLE_KEY_ID length: ${#APPLE_KEY_ID}" echo "APPLE_ISSUER_ID length: ${#APPLE_ISSUER_ID}" echo "APPLE_API_KEY_P8_BASE64 length: ${#APPLE_API_KEY_P8_BASE64}" echo "APPLE_DIST_CERT_P12_BASE64 length: ${#APPLE_DIST_CERT_P12_BASE64}" echo "APPLE_DIST_CERT_P12_PASSWORD length: ${#APPLE_DIST_CERT_P12_PASSWORD}" echo "APPLE_PROVISIONING_PROFILE_BASE64 length: ${#APPLE_PROVISIONING_PROFILE_BASE64}" echo "APPLE_KEYCHAIN_PASSWORD length: ${#APPLE_KEYCHAIN_PASSWORD}" set -e # Fail fast on any zero-length secret so we don't waste time on the # subsequent steps. for n in TEAM_ID KEY_ID ISSUER_ID API_KEY_P8_BASE64 DIST_CERT_P12_BASE64 DIST_CERT_P12_PASSWORD PROVISIONING_PROFILE_BASE64 KEYCHAIN_PASSWORD; do var="APPLE_$n" if [[ -z "${!var}" ]]; then echo "FATAL: $var is empty — set it as a Gitea repo secret on the repo this workflow runs in (Builds/hash-wallet)." exit 1 fi done - 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') # Decode the user-uploaded .p12 directly. macOS Keychain Access # exports .p12 with legacy PKCS12 crypto (RC2-40-CBC, PBE-SHA1) # which is exactly what `security import` expects — no openssl # conversion needed. Earlier attempts to "convert to legacy" # via openssl 3.x failed because openssl 3.x has RC2-40-CBC # disabled by default; that was a non-problem we made for # ourselves. The original "Unknown format" failure was caused # by a truncated base64 secret value, since fixed by re-pasting # the secret via Gitea's API. P12="$RUNNER_TEMP/dist.p12" echo "$APPLE_DIST_CERT_P12_BASE64" | openssl base64 -d -A > "$P12" echo "=== decoded .p12 ===" ls -la "$P12" xxd "$P12" | head -1 file "$P12" || true 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" | openssl base64 -d -A > "$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" P8="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_KEY_ID}.p8" echo "$APPLE_API_KEY_P8_BASE64" | openssl base64 -d -A > "$P8" ls -la "$HOME/.appstoreconnect/private_keys/" # Diagnostics — verify the PEM file is well-formed without # printing the private key body. A valid AuthKey .p8 must: # - Start with "-----BEGIN PRIVATE KEY-----" # - End with "-----END PRIVATE KEY-----" # - openssl pkey -in file -noout must succeed (real ASN.1 parse) echo "=== .p8 first/last lines (no key body) ===" head -1 "$P8" tail -1 "$P8" echo "=== openssl parse check (silent on success) ===" if openssl pkey -in "$P8" -noout 2>&1; then echo "OK — .p8 parses as a valid EC private key" else echo "FATAL: .p8 doesn't parse — secret likely truncated or wrong content" echo "Re-paste APPLE_API_KEY_P8_BASE64 via the Gitea API (same flow" echo "as the .p12 re-paste); the file decoded to:" wc -c "$P8" exit 1 fi # ---- 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: Compile SVG assets (res/pictures/*.svg → assets/new-ui/*.svg.vec) run: ./compile_graphics.sh - name: pod install run: | cd ios pod install --repo-update # ---- Override signing in project.pbxproj for the archive step ------- # The committed Release config uses Automatic signing + "Apple # Development" identity (set up for local dev where Xcode is signed # into an Apple ID). On CI we have only the cert + provisioning # profile we installed in the temp keychain; no Xcode account. # # First attempt was an xcconfig append, but Xcode build-setting # precedence puts target-level pbxproj settings ABOVE xcconfig — # so the pbxproj values won and the build did Automatic signing # anyway. Patch the Release config in pbxproj directly, only # within the Runner target block (id 97C147071CF9000F007C117D); # leaves Debug/Profile configs untouched so local builds stay # on Automatic signing. - name: Override signing settings in pbxproj for archive env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -x PBX=ios/Runner.xcodeproj/project.pbxproj # BSD sed (macOS): -i '' for in-place, regex range scoped to # the Runner target's Release config by its stable UUID. sed -i '' "/97C147071CF9000F007C117D \/\* Release/,/name = Release;/ { s/CODE_SIGN_IDENTITY = \"Apple Development\";/CODE_SIGN_IDENTITY = \"Apple Distribution\";/ s/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/ s/DEVELOPMENT_TEAM = [A-Z0-9]\{1,\};/DEVELOPMENT_TEAM = ${APPLE_TEAM_ID};/ s|PROVISIONING_PROFILE_SPECIFIER = \"\";|PROVISIONING_PROFILE_SPECIFIER = \"${PROFILE_NAME}\";| }" "$PBX" echo "=== patched Runner Release config ===" sed -n '/97C147071CF9000F007C117D \/\* Release/,/name = Release;/p' "$PBX" # ---- Generate ExportOptions.plist + build IPA ----------------------- - name: Write ExportOptions.plist env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | cat > ios/ExportOptions.plist < method app-store teamID ${APPLE_TEAM_ID} signingStyle manual signingCertificate Apple Distribution provisioningProfiles com.suchsoftware.hashwallet ${PROFILE_NAME} uploadBitcode uploadSymbols destination export stripSwiftSymbols 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