Skip to content

Commit c9a95e6

Browse files
committed
ci(ios): automate App Store Connect uploads
1 parent 1c1a3f3 commit c9a95e6

2 files changed

Lines changed: 304 additions & 1 deletion

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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

makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all setup clean deps deps-update bootstrap analyze build-runner translations hooks ios-pod-update drift-migrations devcontainer devcontainer-up container-tools container-app android release debug beta verify verify-rustc-pins test unit-test integration-test catalogue fvm-check
1+
.PHONY: all setup clean deps deps-update bootstrap analyze build-runner translations hooks ios-pod-update ios-release drift-migrations devcontainer devcontainer-up container-tools container-app android release debug beta verify verify-rustc-pins test unit-test integration-test catalogue fvm-check
22

33
fvm-check:
44
@echo "🔍 Checking FVM"
@@ -98,6 +98,12 @@ ios-sqlite-update:
9898
@echo "Updating SQLite"
9999
@cd ios && pod update sqlite3 && cd -
100100

101+
ios-release:
102+
@if [ "$$(uname)" != "Darwin" ]; then echo "iOS releases require macOS"; exit 1; fi
103+
@if [ -z "$(BUILD_NUMBER)" ]; then echo "BUILD_NUMBER is required"; exit 1; fi
104+
@echo "Building App Store IPA (build $(BUILD_NUMBER))"
105+
@fvm flutter build ipa --release --build-number "$(BUILD_NUMBER)"
106+
101107
# Container runtime — default podman, override with CONTAINER=docker for
102108
# environments without podman.
103109
CONTAINER ?= podman

0 commit comments

Comments
 (0)