|
| 1 | +name: Upload App Store Connect |
| 2 | + |
| 3 | +run-name: App Store Connect ${{ github.ref_name }} by @${{ github.actor }} |
| 4 | + |
| 5 | +on: |
| 6 | + workflow_dispatch: |
| 7 | + inputs: |
| 8 | + build_number: |
| 9 | + description: Optional CFBundleVersion override (defaults to pubspec build + workflow run number) |
| 10 | + required: false |
| 11 | + type: string |
| 12 | + |
| 13 | +# Never cancel an upload already in progress, and serialize build-number use. |
| 14 | +concurrency: |
| 15 | + group: upload-app-store-connect |
| 16 | + cancel-in-progress: false |
| 17 | + |
| 18 | +jobs: |
| 19 | + release: |
| 20 | + # macos-15 is the stable arm64 image. Apple requires Xcode 26 / iOS 26 SDK |
| 21 | + # for uploads since 2026-04-28; the image's default Xcode is still 16.4. |
| 22 | + runs-on: macos-15 |
| 23 | + timeout-minutes: 120 |
| 24 | + environment: app-store-connect-upload |
| 25 | + permissions: |
| 26 | + contents: read |
| 27 | + env: |
| 28 | + RUST_VERSION: 1.95.0 |
| 29 | + BDK_RUST_VERSION: 1.85.1 |
| 30 | + RUSTUP_TOOLCHAIN: 1.95.0 |
| 31 | + RUSTUP_AUTO_INSTALL: 0 |
| 32 | + CARGO_NET_GIT_FETCH_WITH_CLI: true |
| 33 | + CARGO_INCREMENTAL: 0 |
| 34 | + |
| 35 | + steps: |
| 36 | + - name: Checkout selected branch |
| 37 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 38 | + |
| 39 | + - name: Validate App Store Connect configuration |
| 40 | + env: |
| 41 | + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} |
| 42 | + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} |
| 43 | + APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} |
| 44 | + IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} |
| 45 | + IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} |
| 46 | + IOS_APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROVISIONING_PROFILE_BASE64 }} |
| 47 | + run: | |
| 48 | + missing=0 |
| 49 | + for name in \ |
| 50 | + APP_STORE_CONNECT_API_KEY_ID \ |
| 51 | + APP_STORE_CONNECT_ISSUER_ID \ |
| 52 | + APP_STORE_CONNECT_API_PRIVATE_KEY \ |
| 53 | + IOS_DISTRIBUTION_CERTIFICATE_BASE64 \ |
| 54 | + IOS_DISTRIBUTION_CERTIFICATE_PASSWORD \ |
| 55 | + IOS_APP_STORE_PROVISIONING_PROFILE_BASE64; do |
| 56 | + if [ -z "${!name}" ]; then |
| 57 | + echo "::error::$name is not configured in the app-store-connect-upload environment." |
| 58 | + missing=1 |
| 59 | + fi |
| 60 | + done |
| 61 | + exit "$missing" |
| 62 | +
|
| 63 | + - name: Resolve release metadata |
| 64 | + id: metadata |
| 65 | + env: |
| 66 | + BUILD_NUMBER_INPUT: ${{ inputs.build_number }} |
| 67 | + run: | |
| 68 | + version_line="$(awk '/^version:/ { print $2; exit }' pubspec.yaml)" |
| 69 | + base_build="${version_line##*+}" |
| 70 | +
|
| 71 | + if ! [[ "$base_build" =~ ^[1-9][0-9]*$ ]]; then |
| 72 | + echo "::error::pubspec.yaml must contain a positive integer build number." |
| 73 | + exit 1 |
| 74 | + fi |
| 75 | +
|
| 76 | + if [ -n "$BUILD_NUMBER_INPUT" ]; then |
| 77 | + if ! [[ "$BUILD_NUMBER_INPUT" =~ ^[1-9][0-9]*$ ]]; then |
| 78 | + echo "::error::build_number must be a positive integer." |
| 79 | + exit 1 |
| 80 | + fi |
| 81 | + build_number="$BUILD_NUMBER_INPUT" |
| 82 | + else |
| 83 | + build_number=$((base_build + GITHUB_RUN_NUMBER)) |
| 84 | + fi |
| 85 | +
|
| 86 | + echo "build_number=$build_number" >> "$GITHUB_OUTPUT" |
| 87 | + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" |
| 88 | + echo "BUILD_NUMBER=$build_number" >> "$GITHUB_ENV" |
| 89 | +
|
| 90 | + - name: Select Xcode 26.3 |
| 91 | + run: | |
| 92 | + sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer |
| 93 | + xcodebuild -version |
| 94 | + test "$(xcodebuild -version | awk 'NR == 1 { print $2 }')" = "26.3" |
| 95 | + test "$(xcrun --sdk iphoneos --show-sdk-version | cut -d. -f1)" = "26" |
| 96 | +
|
| 97 | + - name: Cache Flutter SDK |
| 98 | + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 |
| 99 | + with: |
| 100 | + path: ~/fvm/versions |
| 101 | + key: fvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.fvmrc') }} |
| 102 | + |
| 103 | + - name: Cache pub dependencies |
| 104 | + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 |
| 105 | + with: |
| 106 | + path: ~/.pub-cache |
| 107 | + key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} |
| 108 | + |
| 109 | + - name: Cache Cargo sources |
| 110 | + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 |
| 111 | + with: |
| 112 | + path: | |
| 113 | + ~/.cargo/registry |
| 114 | + ~/.cargo/git |
| 115 | + key: cargo-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} |
| 116 | + |
| 117 | + - name: Cache CocoaPods downloads |
| 118 | + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 |
| 119 | + with: |
| 120 | + path: ~/Library/Caches/CocoaPods |
| 121 | + key: cocoapods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} |
| 122 | + |
| 123 | + # Install the pinned native FVM binary without executing the mutable |
| 124 | + # fvm.app installer script on a runner that later receives signing keys. |
| 125 | + - name: Install FVM 4.1.1 |
| 126 | + run: | |
| 127 | + archive="$RUNNER_TEMP/fvm-4.1.1-macos-arm64.tar.gz" |
| 128 | + curl -fsSL \ |
| 129 | + https://github.com/conceptadev/fvm/releases/download/4.1.1/fvm-4.1.1-macos-arm64.tar.gz \ |
| 130 | + -o "$archive" |
| 131 | + echo "ffbcc64c227b33046b0b3aee0829466a8900a3c85d976c7f8449220c0730aad9 $archive" \ |
| 132 | + | shasum -a 256 -c - |
| 133 | + mkdir -p "$HOME/fvm/bin" |
| 134 | + tar -xzf "$archive" -C "$HOME/fvm/bin" --strip-components=1 fvm/fvm |
| 135 | + chmod +x "$HOME/fvm/bin/fvm" |
| 136 | + test -x "$HOME/fvm/bin/fvm" |
| 137 | + echo "$HOME/fvm/bin" >> "$GITHUB_PATH" |
| 138 | +
|
| 139 | + - name: Install pinned Rust toolchains |
| 140 | + run: | |
| 141 | + rustup toolchain install "$RUST_VERSION" --profile minimal |
| 142 | + rustup component add --toolchain "$RUST_VERSION" clippy rustfmt |
| 143 | + rustup target add --toolchain "$RUST_VERSION" aarch64-apple-ios |
| 144 | +
|
| 145 | + rustup toolchain install "$BDK_RUST_VERSION" --profile minimal |
| 146 | + rustup component add --toolchain "$BDK_RUST_VERSION" clippy rustfmt |
| 147 | + rustup target add --toolchain "$BDK_RUST_VERSION" aarch64-apple-ios |
| 148 | +
|
| 149 | + # Cargokit requests `rustup run stable` directly, which otherwise |
| 150 | + # bypasses RUSTUP_TOOLCHAIN and floats every six weeks. bdk_dart's |
| 151 | + # explicit 1.85.1 request passes through unchanged. |
| 152 | + rustup_path="$(command -v rustup)" |
| 153 | + mv "$rustup_path" "$rustup_path.real" |
| 154 | + cat > "$rustup_path" <<'EOF' |
| 155 | + #!/bin/bash |
| 156 | + args=() |
| 157 | + for arg in "$@"; do |
| 158 | + [ "$arg" = "stable" ] && arg="1.95.0" |
| 159 | + args+=("$arg") |
| 160 | + done |
| 161 | + exec "$(dirname "$0")/rustup.real" "${args[@]}" |
| 162 | + EOF |
| 163 | + chmod +x "$rustup_path" |
| 164 | +
|
| 165 | + rustup run stable rustc --version | grep -F "rustc $RUST_VERSION" |
| 166 | + rustup run "$BDK_RUST_VERSION" rustc --version | grep -F "rustc $BDK_RUST_VERSION" |
| 167 | +
|
| 168 | + - name: Install Flutter and generate sources |
| 169 | + run: | |
| 170 | + make fvm-check |
| 171 | + make deps |
| 172 | + make build-runner |
| 173 | + make translations |
| 174 | + git diff --exit-code |
| 175 | +
|
| 176 | + - name: Install locked CocoaPods dependencies |
| 177 | + working-directory: ios |
| 178 | + run: | |
| 179 | + test -f Podfile.lock |
| 180 | + pod install --deployment |
| 181 | +
|
| 182 | + - name: Import App Store distribution certificate |
| 183 | + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 |
| 184 | + with: |
| 185 | + p12-file-base64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} |
| 186 | + p12-password: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} |
| 187 | + |
| 188 | + - name: Install App Store provisioning profile |
| 189 | + env: |
| 190 | + PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROVISIONING_PROFILE_BASE64 }} |
| 191 | + run: | |
| 192 | + encoded_profile="$RUNNER_TEMP/profile.b64" |
| 193 | + decoded_profile="$RUNNER_TEMP/profile.mobileprovision" |
| 194 | + profile_plist="$RUNNER_TEMP/profile.plist" |
| 195 | + printf '%s' "$PROFILE_BASE64" > "$encoded_profile" |
| 196 | + base64 -D -i "$encoded_profile" -o "$decoded_profile" |
| 197 | + security cms -D -i "$decoded_profile" > "$profile_plist" |
| 198 | +
|
| 199 | + profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")" |
| 200 | + app_identifier="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$profile_plist")" |
| 201 | + get_task_allow="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:get-task-allow' "$profile_plist")" |
| 202 | + if [[ "$app_identifier" != *.com.bullbitcoin.app ]] || [ "$get_task_allow" != "false" ]; then |
| 203 | + echo "::error::The provisioning profile is not an App Store profile for com.bullbitcoin.app." |
| 204 | + exit 1 |
| 205 | + fi |
| 206 | + if /usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' "$profile_plist" >/dev/null 2>&1; then |
| 207 | + echo "::error::Ad hoc and development provisioning profiles are not accepted." |
| 208 | + exit 1 |
| 209 | + fi |
| 210 | + if /usr/libexec/PlistBuddy -c 'Print :ProvisionsAllDevices' "$profile_plist" >/dev/null 2>&1; then |
| 211 | + echo "::error::Enterprise provisioning profiles are not accepted." |
| 212 | + exit 1 |
| 213 | + fi |
| 214 | +
|
| 215 | + profile_dir="$HOME/Library/MobileDevice/Provisioning Profiles" |
| 216 | + profile_path="$profile_dir/$profile_uuid.mobileprovision" |
| 217 | + mkdir -p "$profile_dir" |
| 218 | + install -m 600 "$decoded_profile" "$profile_path" |
| 219 | + echo "IOS_PROVISIONING_PROFILE_PATH=$profile_path" >> "$GITHUB_ENV" |
| 220 | +
|
| 221 | + - name: Build signed IPA |
| 222 | + run: make ios-release BUILD_NUMBER="$BUILD_NUMBER" |
| 223 | + |
| 224 | + - name: Verify signed IPA |
| 225 | + id: ipa |
| 226 | + run: | |
| 227 | + ipa_files=(build/ios/ipa/*.ipa) |
| 228 | + if [ "${#ipa_files[@]}" -ne 1 ] || [ ! -f "${ipa_files[0]}" ]; then |
| 229 | + echo "::error::Expected exactly one IPA in build/ios/ipa." |
| 230 | + exit 1 |
| 231 | + fi |
| 232 | +
|
| 233 | + ipa_path="${ipa_files[0]}" |
| 234 | + verify_dir="$RUNNER_TEMP/verify-ipa" |
| 235 | + mkdir -p "$verify_dir" |
| 236 | + unzip -q "$ipa_path" -d "$verify_dir" |
| 237 | + apps=("$verify_dir"/Payload/*.app) |
| 238 | + if [ "${#apps[@]}" -ne 1 ] || [ ! -d "${apps[0]}" ]; then |
| 239 | + echo "::error::Expected exactly one application in the IPA payload." |
| 240 | + exit 1 |
| 241 | + fi |
| 242 | +
|
| 243 | + codesign --verify --deep --strict --verbose=2 "${apps[0]}" |
| 244 | + actual_build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "${apps[0]}/Info.plist")" |
| 245 | + actual_bundle="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${apps[0]}/Info.plist")" |
| 246 | + test "$actual_build" = "$BUILD_NUMBER" |
| 247 | + test "$actual_bundle" = "com.bullbitcoin.app" |
| 248 | +
|
| 249 | + dsyms=(build/ios/archive/*.xcarchive/dSYMs) |
| 250 | + if [ "${#dsyms[@]}" -ne 1 ] || [ ! -d "${dsyms[0]}" ]; then |
| 251 | + echo "::error::Expected one dSYM directory in the Xcode archive." |
| 252 | + exit 1 |
| 253 | + fi |
| 254 | +
|
| 255 | + echo "path=$ipa_path" >> "$GITHUB_OUTPUT" |
| 256 | +
|
| 257 | + - name: Record artifact hash |
| 258 | + env: |
| 259 | + IPA_PATH: ${{ steps.ipa.outputs.path }} |
| 260 | + run: | |
| 261 | + echo "### App Store Connect build" >> "$GITHUB_STEP_SUMMARY" |
| 262 | + echo "- Ref: \`$GITHUB_REF_NAME\`" >> "$GITHUB_STEP_SUMMARY" |
| 263 | + echo "- Commit: \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY" |
| 264 | + echo "- Build number: \`$BUILD_NUMBER\`" >> "$GITHUB_STEP_SUMMARY" |
| 265 | + echo "- Xcode: \`$(xcodebuild -version | tr '\n' ' ')\`" >> "$GITHUB_STEP_SUMMARY" |
| 266 | + echo '```' >> "$GITHUB_STEP_SUMMARY" |
| 267 | + shasum -a 256 "$IPA_PATH" >> "$GITHUB_STEP_SUMMARY" |
| 268 | + echo '```' >> "$GITHUB_STEP_SUMMARY" |
| 269 | +
|
| 270 | + - name: Preserve IPA and dSYMs |
| 271 | + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| 272 | + with: |
| 273 | + name: BULL-app-store-connect-${{ steps.metadata.outputs.sha_short }}-${{ steps.metadata.outputs.build_number }} |
| 274 | + path: | |
| 275 | + build/ios/ipa/*.ipa |
| 276 | + build/ios/archive/*.xcarchive/dSYMs |
| 277 | + if-no-files-found: error |
| 278 | + retention-days: 30 |
| 279 | + |
| 280 | + # This Developer API key only uploads the processed build. TestFlight |
| 281 | + # distribution and App Store submission remain manual Apple-side gates. |
| 282 | + - name: Upload build to App Store Connect |
| 283 | + uses: apple-actions/upload-testflight-build@5e75ff58276689011512ba87a381d93dc67dbcf8 # v5.3.0 |
| 284 | + with: |
| 285 | + app-path: ${{ steps.ipa.outputs.path }} |
| 286 | + issuer-id: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} |
| 287 | + api-key-id: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} |
| 288 | + api-private-key: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }} |
| 289 | + backend: appstore-api |
| 290 | + wait-for-processing: true |
| 291 | + |
| 292 | + - name: Remove provisioning profile |
| 293 | + if: always() |
| 294 | + run: | |
| 295 | + if [ -n "$IOS_PROVISIONING_PROFILE_PATH" ]; then |
| 296 | + rm -f "$IOS_PROVISIONING_PROFILE_PATH" |
| 297 | + fi |
0 commit comments