App Store Connect fix-apple-ci by @ethicnology #11
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: Upload App Store Connect | |
| run-name: App Store Connect ${{ github.ref_name }} by @${{ github.actor }} | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| build_number: | |
| description: Optional CFBundleVersion override (defaults to pubspec build + workflow run number) | |
| required: false | |
| type: string | |
| # Never cancel an upload already in progress, and serialize build-number use. | |
| concurrency: | |
| group: upload-app-store-connect | |
| cancel-in-progress: false | |
| jobs: | |
| release: | |
| # macos-15 is the stable arm64 image. Apple requires Xcode 26 / iOS 26 SDK | |
| # for uploads since 2026-04-28; the image's default Xcode is still 16.4. | |
| runs-on: macos-15 | |
| timeout-minutes: 120 | |
| environment: app-store-connect-upload | |
| permissions: | |
| contents: read | |
| env: | |
| RUST_VERSION: 1.95.0 | |
| BDK_RUST_VERSION: 1.85.1 | |
| RUSTUP_TOOLCHAIN: 1.95.0 | |
| RUSTUP_AUTO_INSTALL: 0 | |
| CARGO_NET_GIT_FETCH_WITH_CLI: true | |
| CARGO_INCREMENTAL: 0 | |
| # TEMPORARY, until the Rust build is proven — see "Build signed IPA". | |
| # Documented rustup knobs (rustup book, Environment variables): DEBUG | |
| # logging so the component installer explains itself, and single-threaded | |
| # download/IO so rustup's own internal concurrency is out of the picture. | |
| RUSTUP_LOG: rustup=DEBUG | |
| RUSTUP_CONCURRENT_DOWNLOADS: 1 | |
| RUSTUP_IO_THREADS: 1 | |
| steps: | |
| - name: Checkout selected branch | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Validate App Store Connect configuration | |
| env: | |
| APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} | |
| IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} | |
| IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} | |
| IOS_APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| missing=0 | |
| for name in \ | |
| APP_STORE_CONNECT_API_KEY_ID \ | |
| APP_STORE_CONNECT_ISSUER_ID \ | |
| APP_STORE_CONNECT_API_PRIVATE_KEY \ | |
| IOS_DISTRIBUTION_CERTIFICATE_BASE64 \ | |
| IOS_DISTRIBUTION_CERTIFICATE_PASSWORD \ | |
| IOS_APP_STORE_PROVISIONING_PROFILE_BASE64; do | |
| if [ -z "${!name}" ]; then | |
| echo "::error::$name is not configured in the app-store-connect-upload environment." | |
| missing=1 | |
| fi | |
| done | |
| exit "$missing" | |
| - name: Resolve release metadata | |
| id: metadata | |
| env: | |
| BUILD_NUMBER_INPUT: ${{ inputs.build_number }} | |
| run: | | |
| version_line="$(awk '/^version:/ { print $2; exit }' pubspec.yaml)" | |
| base_build="${version_line##*+}" | |
| if ! [[ "$base_build" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::pubspec.yaml must contain a positive integer build number." | |
| exit 1 | |
| fi | |
| if [ -n "$BUILD_NUMBER_INPUT" ]; then | |
| if ! [[ "$BUILD_NUMBER_INPUT" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::build_number must be a positive integer." | |
| exit 1 | |
| fi | |
| build_number="$BUILD_NUMBER_INPUT" | |
| else | |
| build_number=$((base_build + GITHUB_RUN_NUMBER)) | |
| fi | |
| echo "build_number=$build_number" >> "$GITHUB_OUTPUT" | |
| echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "BUILD_NUMBER=$build_number" >> "$GITHUB_ENV" | |
| - name: Select Xcode 26.3 | |
| run: | | |
| sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer | |
| xcodebuild -version | |
| test "$(xcodebuild -version | awk 'NR == 1 { print $2 }')" = "26.3" | |
| test "$(xcrun --sdk iphoneos --show-sdk-version | cut -d. -f1)" = "26" | |
| - name: Cache Flutter SDK | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/fvm/versions | |
| key: fvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.fvmrc') }} | |
| - name: Cache pub dependencies | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.pub-cache | |
| key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} | |
| - name: Cache Cargo sources | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| key: cargo-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} | |
| - name: Cache CocoaPods downloads | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/Library/Caches/CocoaPods | |
| key: cocoapods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} | |
| # Install the pinned native FVM binary without executing the mutable | |
| # fvm.app installer script on a runner that later receives signing keys. | |
| - name: Install FVM 4.1.2 | |
| run: | | |
| archive="$RUNNER_TEMP/fvm-4.1.2-macos-arm64.tar.gz" | |
| curl -fsSL \ | |
| https://github.com/leoafarias/fvm/releases/download/4.1.2/fvm-4.1.2-macos-arm64.tar.gz \ | |
| -o "$archive" | |
| echo "0b2a146986c51f06331f135f0bdf2a202eb57f55d7edd420c9078e8520e4c033 $archive" \ | |
| | shasum -a 256 -c - | |
| mkdir -p "$HOME/fvm/bin" | |
| tar -xzf "$archive" -C "$HOME/fvm/bin" --strip-components=1 fvm/fvm | |
| chmod +x "$HOME/fvm/bin/fvm" | |
| test -x "$HOME/fvm/bin/fvm" | |
| echo "$HOME/fvm/bin" >> "$GITHUB_PATH" | |
| - name: Install pinned Rust toolchains | |
| run: | | |
| rustup toolchain install "$RUST_VERSION" --profile minimal | |
| rustup component add --toolchain "$RUST_VERSION" clippy rustfmt | |
| rustup target add --toolchain "$RUST_VERSION" aarch64-apple-ios | |
| rustup toolchain install "$BDK_RUST_VERSION" --profile minimal | |
| rustup component add --toolchain "$BDK_RUST_VERSION" clippy rustfmt | |
| rustup target add --toolchain "$BDK_RUST_VERSION" aarch64-apple-ios | |
| # The wrapper does two things. | |
| # | |
| # 1. Cargokit requests `rustup run stable` directly, which otherwise | |
| # bypasses RUSTUP_TOOLCHAIN and floats every six weeks. bdk_dart's | |
| # explicit 1.85.1 request passes through unchanged. Any bare | |
| # `stable` argument is rewritten, wherever it sits in argv. | |
| # | |
| # 2. Concurrent rustup invocations corrupt each other's component | |
| # downloads (rust-lang/rustup#3690, #4910). The Xcode archive | |
| # builds several Rust crates at once, and the archive failed on | |
| # both symptoms: a half-renamed download, then a component | |
| # conflict. Serialize every invocation behind a mutex. `mkdir` is | |
| # atomic on HFS+/APFS and needs no extra tooling — macOS has no | |
| # flock(1). The lock is bounded so a crashed holder cannot wedge | |
| # the build, and released on every exit path. | |
| rustup_path="$(command -v rustup)" | |
| mv "$rustup_path" "$rustup_path.real" | |
| cat > "$rustup_path" <<EOF | |
| #!/bin/bash | |
| args=() | |
| for arg in "\$@"; do | |
| [ "\$arg" = "stable" ] && arg="$RUST_VERSION" | |
| args+=("\$arg") | |
| done | |
| lock="\${RUSTUP_HOME:-\$HOME/.rustup}/.serialize.lock" | |
| mkdir -p "\$(dirname "\$lock")" | |
| waited=0 | |
| until mkdir "\$lock" 2>/dev/null; do | |
| if [ "\$waited" -ge 900 ]; then | |
| echo "rustup: stale lock at \$lock after \${waited}s, taking it" >&2 | |
| break | |
| fi | |
| sleep 1 | |
| waited=\$((waited + 1)) | |
| done | |
| trap 'rmdir "\$lock" 2>/dev/null' EXIT INT TERM | |
| "\$(dirname "\$0")/rustup.real" "\${args[@]}" | |
| EOF | |
| chmod +x "$rustup_path" | |
| rustup run stable rustc --version | grep -F "rustc $RUST_VERSION" | |
| rustup run "$BDK_RUST_VERSION" rustc --version | grep -F "rustc $BDK_RUST_VERSION" | |
| # TEMPORARY. The wrapper above only covers the rustup on this step's | |
| # PATH; a second copy elsewhere on the image would bypass both the | |
| # pin and the mutex, which would explain why serializing changed | |
| # nothing. List every candidate, and record what is already installed | |
| # so a later install attempt can be told apart from a missing target. | |
| echo "::group::rustup binaries on PATH" | |
| type -a rustup || true | |
| ls -l "$(command -v rustup)" "$(command -v rustup).real" || true | |
| echo "::endgroup::" | |
| echo "::group::installed toolchains and targets" | |
| rustup toolchain list | |
| rustup target list --installed --toolchain "$RUST_VERSION" | |
| rustup target list --installed --toolchain "$BDK_RUST_VERSION" | |
| echo "::endgroup::" | |
| - name: Install Flutter and generate sources | |
| run: | | |
| make fvm-check | |
| make deps | |
| fvm flutter precache --ios | |
| make build-runner | |
| make translations | |
| if [ -n "$(git status --porcelain)" ]; then | |
| git status --porcelain | |
| echo "::error::Code generation changed the checkout; commit the regenerated files." | |
| exit 1 | |
| fi | |
| - name: Install locked CocoaPods dependencies | |
| working-directory: ios | |
| run: | | |
| test -f Podfile.lock | |
| # The image's CocoaPods floats (1.16 -> 1.17 changed podspec checksums, | |
| # which `--deployment` rejects). Pin to whatever generated Podfile.lock | |
| # so the lockfile stays the single source of truth across image bumps. | |
| locked_version="$(awk '/^COCOAPODS:/ { print $2; exit }' Podfile.lock)" | |
| test -n "$locked_version" | |
| if [ "$(pod --version)" != "$locked_version" ]; then | |
| sudo gem install cocoapods -v "$locked_version" --no-document | |
| fi | |
| test "$(pod "_${locked_version}_" --version)" = "$locked_version" | |
| pod "_${locked_version}_" install --deployment | |
| - name: Import App Store distribution certificate | |
| uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 | |
| with: | |
| p12-file-base64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} | |
| p12-password: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} | |
| - name: Install App Store provisioning profile | |
| env: | |
| PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| encoded_profile="$RUNNER_TEMP/profile.b64" | |
| decoded_profile="$RUNNER_TEMP/profile.mobileprovision" | |
| profile_plist="$RUNNER_TEMP/profile.plist" | |
| printf '%s' "$PROFILE_BASE64" > "$encoded_profile" | |
| base64 -D -i "$encoded_profile" -o "$decoded_profile" | |
| security cms -D -i "$decoded_profile" > "$profile_plist" | |
| profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")" | |
| app_identifier="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$profile_plist")" | |
| get_task_allow="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:get-task-allow' "$profile_plist")" | |
| if [ "$app_identifier" != "BX99T32YGS.com.bullbitcoin.app" ] || [ "$get_task_allow" != "false" ]; then | |
| echo "::error::The provisioning profile is not an App Store profile for com.bullbitcoin.app." | |
| exit 1 | |
| fi | |
| if /usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' "$profile_plist" >/dev/null 2>&1; then | |
| echo "::error::Ad hoc and development provisioning profiles are not accepted." | |
| exit 1 | |
| fi | |
| if /usr/libexec/PlistBuddy -c 'Print :ProvisionsAllDevices' "$profile_plist" >/dev/null 2>&1; then | |
| echo "::error::Enterprise provisioning profiles are not accepted." | |
| exit 1 | |
| fi | |
| # Xcode 16 moved the provisioning profile directory; install to both. | |
| legacy_dir="$HOME/Library/MobileDevice/Provisioning Profiles" | |
| xcode_dir="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" | |
| mkdir -p "$legacy_dir" "$xcode_dir" | |
| install -m 600 "$decoded_profile" "$legacy_dir/$profile_uuid.mobileprovision" | |
| install -m 600 "$decoded_profile" "$xcode_dir/$profile_uuid.mobileprovision" | |
| echo "IOS_PROVISIONING_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" | |
| - name: Configure manual signing | |
| run: | | |
| # The Runner target's Release configuration (97C14707...) has no | |
| # signing settings of its own: it inherits automatic signing and the | |
| # project-level "iPhone Developer" identity. Point it at the | |
| # installed distribution certificate and profile instead. | |
| awk -v uuid="$IOS_PROVISIONING_PROFILE_UUID" ' | |
| index($0, "97C147071CF9000F007C117D /* Release */ = {") { in_release = 1 } | |
| in_release && $0 == "\t\t\t};" && !inserted { | |
| print "\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Distribution\";" | |
| print "\t\t\t\tCODE_SIGN_STYLE = Manual;" | |
| print "\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"" uuid "\";" | |
| inserted = 1 | |
| } | |
| { print } | |
| END { if (!inserted) exit 1 } | |
| ' ios/Runner.xcodeproj/project.pbxproj > "$RUNNER_TEMP/project.pbxproj" \ | |
| && mv "$RUNNER_TEMP/project.pbxproj" ios/Runner.xcodeproj/project.pbxproj | |
| grep -F "PROVISIONING_PROFILE_SPECIFIER = \"$IOS_PROVISIONING_PROFILE_UUID\";" \ | |
| ios/Runner.xcodeproj/project.pbxproj | |
| export_options="$RUNNER_TEMP/ExportOptions.plist" | |
| cat > "$export_options" <<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-connect</string> | |
| <key>signingStyle</key> | |
| <string>manual</string> | |
| <key>signingCertificate</key> | |
| <string>Apple Distribution</string> | |
| <key>teamID</key> | |
| <string>BX99T32YGS</string> | |
| <key>manageAppVersionAndBuildNumber</key> | |
| <false/> | |
| <key>provisioningProfiles</key> | |
| <dict> | |
| <key>com.bullbitcoin.app</key> | |
| <string>$IOS_PROVISIONING_PROFILE_UUID</string> | |
| </dict> | |
| </dict> | |
| </plist> | |
| EOF | |
| plutil -lint "$export_options" | |
| echo "EXPORT_OPTIONS_PLIST=$export_options" >> "$GITHUB_ENV" | |
| - name: Build signed IPA | |
| run: | | |
| # TEMPORARY --verbose: Flutter surfaces only SEVERE lines from | |
| # xcodebuild, so the Rust build phase's own output — including | |
| # rustup's explanation of the failing component install — never | |
| # reaches the log. Drop this once the archive completes. | |
| set -o pipefail | |
| make ios-release \ | |
| BUILD_NUMBER="$BUILD_NUMBER" \ | |
| EXPORT_OPTIONS_PLIST="$EXPORT_OPTIONS_PLIST" \ | |
| FLUTTER_EXTRA_ARGS=--verbose | |
| echo "disk after build:"; df -h / | |
| echo "rustup downloads dir:"; ls -la "$HOME/.rustup/downloads" || true | |
| - name: Verify signed IPA | |
| id: ipa | |
| run: | | |
| ipa_files=(build/ios/ipa/*.ipa) | |
| if [ "${#ipa_files[@]}" -ne 1 ] || [ ! -f "${ipa_files[0]}" ]; then | |
| echo "::error::Expected exactly one IPA in build/ios/ipa." | |
| exit 1 | |
| fi | |
| ipa_path="${ipa_files[0]}" | |
| verify_dir="$RUNNER_TEMP/verify-ipa" | |
| mkdir -p "$verify_dir" | |
| unzip -q "$ipa_path" -d "$verify_dir" | |
| apps=("$verify_dir"/Payload/*.app) | |
| if [ "${#apps[@]}" -ne 1 ] || [ ! -d "${apps[0]}" ]; then | |
| echo "::error::Expected exactly one application in the IPA payload." | |
| exit 1 | |
| fi | |
| codesign --verify --deep --strict --verbose=2 "${apps[0]}" | |
| actual_build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "${apps[0]}/Info.plist")" | |
| actual_bundle="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${apps[0]}/Info.plist")" | |
| test "$actual_build" = "$BUILD_NUMBER" | |
| test "$actual_bundle" = "com.bullbitcoin.app" | |
| dsyms=(build/ios/archive/*.xcarchive/dSYMs) | |
| if [ "${#dsyms[@]}" -ne 1 ] || [ ! -d "${dsyms[0]}" ]; then | |
| echo "::error::Expected one dSYM directory in the Xcode archive." | |
| exit 1 | |
| fi | |
| echo "path=$ipa_path" >> "$GITHUB_OUTPUT" | |
| - name: Record artifact hash | |
| env: | |
| IPA_PATH: ${{ steps.ipa.outputs.path }} | |
| run: | | |
| echo "### App Store Connect build" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Ref: \`$GITHUB_REF_NAME\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Commit: \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Build number: \`$BUILD_NUMBER\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Xcode: \`$(xcodebuild -version | tr '\n' ' ')\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| shasum -a 256 "$IPA_PATH" >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| - name: Preserve IPA and dSYMs | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: BULL-app-store-connect-${{ steps.metadata.outputs.sha_short }}-${{ steps.metadata.outputs.build_number }} | |
| path: | | |
| build/ios/ipa/*.ipa | |
| build/ios/archive/*.xcarchive/dSYMs | |
| if-no-files-found: error | |
| retention-days: 30 | |
| # This Developer API key only uploads the processed build. TestFlight | |
| # distribution and App Store submission remain manual Apple-side gates. | |
| - name: Upload build to App Store Connect | |
| uses: apple-actions/upload-testflight-build@5e75ff58276689011512ba87a381d93dc67dbcf8 # v5.3.0 | |
| with: | |
| app-path: ${{ steps.ipa.outputs.path }} | |
| issuer-id: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| api-key-id: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| api-private-key: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} | |
| backend: appstore-api | |
| wait-for-processing: true | |
| - name: Remove provisioning profile | |
| if: always() | |
| run: | | |
| if [ -n "$IOS_PROVISIONING_PROFILE_UUID" ]; then | |
| rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROVISIONING_PROFILE_UUID.mobileprovision" | |
| rm -f "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/$IOS_PROVISIONING_PROFILE_UUID.mobileprovision" | |
| fi |