Skip to content

App Store Connect fix-apple-ci by @ethicnology #11

App Store Connect fix-apple-ci by @ethicnology

App Store Connect fix-apple-ci by @ethicnology #11

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