-
Notifications
You must be signed in to change notification settings - Fork 6
555 lines (496 loc) · 23.5 KB
/
release-mobile.yml
File metadata and controls
555 lines (496 loc) · 23.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
name: Release Mobile (Tauri)
on:
# Disabled automatic tag triggers until mobile release pipeline is ready.
# push:
# tags:
# - 'v*'
workflow_call:
inputs:
version:
required: true
type: string
release_id:
description: 'Existing GitHub Release ID to upload artifacts to'
required: false
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.2.0)'
required: true
type: string
# Minimal permissions — contents:write for release uploads; id-token and
# attestations:write so actions/attest-build-provenance can publish SLSA
# attestations to the Sigstore transparency log.
permissions:
contents: write
id-token: write
attestations: write
concurrency:
group: release-mobile
cancel-in-progress: false
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
NODE_VERSION: '22'
PNPM_VERSION: '9'
jobs:
android:
runs-on: ubuntu-22.04
name: Release Android (signed APK + AAB)
# Use the 'release' environment for scoped secrets
environment: release
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (pinned 2026-05-12; bump periodically to track new Rust releases)
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android
- name: Rust cache
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
with:
workspaces: src-tauri -> target
- name: Install Linux dependencies
run: |
for i in 1 2 3; do sudo apt-get update && break || sleep $((i*5)); done
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libgtk-3-dev
- name: Setup Java
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
- name: Install Android NDK
run: sdkmanager "ndk;27.0.12077973"
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Decode Android keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > /tmp/keystore.jks
# Verify keystore was decoded correctly
if [ ! -s /tmp/keystore.jks ]; then
echo "Error: keystore file is empty or missing"
exit 1
fi
- name: Initialize Android project
run: pnpm tauri android init
- name: Sync Android version
run: node scripts/sync-version.cjs
- name: Inject custom MainActivity.kt
run: node scripts/inject-android-mainactivity.cjs
- name: Inject signing config into build.gradle.kts
run: node scripts/inject-android-signing.cjs
- name: Build Android (release, signed)
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.12077973
ANDROID_KEYSTORE: /tmp/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: pnpm tauri android build
- name: Rename APK with version
run: |
VERSION="${{ inputs.version || inputs.tag || github.ref_name }}"
VERSION="${VERSION#v}"
APK_DIR="src-tauri/gen/android/app/build/outputs/apk"
AAB_DIR="src-tauri/gen/android/app/build/outputs/bundle"
# Find and rename APKs
find "$APK_DIR" -name '*.apk' -type f | while read -r f; do
dir=$(dirname "$f")
mv "$f" "$dir/forwardemail-mail_${VERSION}_android.apk"
done
# Find and rename AABs
find "$AAB_DIR" -name '*.aab' -type f | while read -r f; do
dir=$(dirname "$f")
mv "$f" "$dir/forwardemail-mail_${VERSION}_android.aab"
done
# SLSA build provenance for the signed APK + AAB. Users verify with
# `gh attestation verify <file> --owner forwardemail`.
- name: Attest Android build provenance
uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2
with:
subject-path: |
src-tauri/gen/android/app/build/outputs/apk/**/forwardemail-mail_*_android.apk
src-tauri/gen/android/app/build/outputs/bundle/**/forwardemail-mail_*_android.aab
- name: Resolve release ID (prefer published over draft)
id: resolve_release
uses: ./.github/actions/resolve-release
with:
tag: v${{ inputs.version || inputs.tag || github.ref_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload signed APK/AAB to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ steps.resolve_release.outputs.release_id }}
TAG: ${{ steps.resolve_release.outputs.tag }}
run: |
set -euo pipefail
FILES=()
while IFS= read -r f; do FILES+=("$f"); done < <(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' -type f)
while IFS= read -r f; do FILES+=("$f"); done < <(find src-tauri/gen/android/app/build/outputs/bundle -name '*.aab' -type f)
if [ ${#FILES[@]} -eq 0 ]; then
echo "::error::No APK/AAB files found to upload"
exit 1
fi
echo "Uploading ${#FILES[@]} file(s) to release id=$RELEASE_ID (tag=$TAG)"
# Upload each file via the GitHub asset upload endpoint
for f in "${FILES[@]}"; do
name=$(basename "$f")
echo " → $name"
# Delete any existing asset with the same name (clobber behavior)
existing_id=$(gh api "repos/${GH_REPO}/releases/${RELEASE_ID}/assets" \
--jq ".[] | select(.name==\"$name\") | .id" | head -n 1)
if [ -n "$existing_id" ]; then
gh api -X DELETE "repos/${GH_REPO}/releases/assets/${existing_id}" || true
fi
gh api \
--method POST \
-H "Content-Type: application/octet-stream" \
"https://uploads.github.com/repos/${GH_REPO}/releases/${RELEASE_ID}/assets?name=${name}" \
--input "$f"
done
- name: Clean up keystore
if: always()
run: |
rm -f /tmp/keystore.jks
# Overwrite before delete to prevent recovery
dd if=/dev/urandom of=/tmp/keystore.jks bs=1024 count=10 2>/dev/null || true
rm -f /tmp/keystore.jks
ios:
runs-on: macos-26
name: Release iOS (signed IPA)
# Use the 'release' environment for scoped secrets
environment: release
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# Skip gracefully (exit 0) when required iOS secrets aren't configured,
# so a missing secret never blocks the desktop/android release pipeline.
# Primary distribution is TestFlight via App Store Connect API, so all
# three ASC secrets are required alongside the signing cert + profile.
# Reuses APPLE_CERTIFICATE if no IOS_CERTIFICATE_BASE64 override is
# provided (works when the .p12 bundle contains an Apple Distribution
# cert alongside the Developer ID macOS cert).
- name: Check iOS release secrets
id: check
shell: bash
env:
APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE }}
IOS_CERT: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
missing=""
if [ -z "$IOS_CERT" ] && [ -z "$APPLE_CERT" ]; then
missing="$missing IOS_CERTIFICATE_BASE64_or_APPLE_CERTIFICATE"
fi
[ -z "$PROFILE" ] && missing="$missing IOS_PROVISIONING_PROFILE_BASE64"
[ -z "$TEAM" ] && missing="$missing APPLE_TEAM_ID"
[ -z "$ASC_KEY" ] && missing="$missing APP_STORE_CONNECT_API_KEY"
[ -z "$ASC_KEY_ID" ] && missing="$missing APP_STORE_CONNECT_KEY_ID"
[ -z "$ASC_ISSUER" ] && missing="$missing APP_STORE_CONNECT_ISSUER_ID"
if [ -n "$missing" ]; then
echo "::warning::Skipping iOS TestFlight release — missing secrets:$missing"
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Select Xcode 26+ toolchain
if: steps.check.outputs.enabled == 'true'
uses: maxim-lobanov/setup-xcode@1242409711ff5721add51979e9e11e23ebb7e5a4 # v1
with:
xcode-version: latest-stable
- name: Show Apple toolchain versions
if: steps.check.outputs.enabled == 'true'
run: |
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
- name: Install Rust stable
if: steps.check.outputs.enabled == 'true'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (pinned 2026-05-12; bump periodically to track new Rust releases)
with:
targets: aarch64-apple-ios
- name: Rust cache
if: steps.check.outputs.enabled == 'true'
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
with:
workspaces: src-tauri -> target
# Bump this prefix whenever IPHONEOS_DEPLOYMENT_TARGET changes.
# tauri-utils's build.rs doesn't emit `rerun-if-env-changed` for
# IPHONEOS_DEPLOYMENT_TARGET, so cargo will happily re-use cached
# .swift.o files compiled against the old target, and the final
# `ld` step will fail against the current iOS SDK (see ios15 fix).
prefix-key: v2-ios16
- name: Setup Node.js
if: steps.check.outputs.enabled == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
if: steps.check.outputs.enabled == 'true'
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- name: Install dependencies
if: steps.check.outputs.enabled == 'true'
run: pnpm install --frozen-lockfile
- name: Import iOS distribution certificate
if: steps.check.outputs.enabled == 'true'
uses: apple-actions/import-codesign-certs@63fff01cd422d4b7b855d40ca1e9d34d2de9427d # v3
with:
p12-file-base64: ${{ secrets.IOS_CERTIFICATE_BASE64 || secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD || secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Install provisioning profile
if: steps.check.outputs.enabled == 'true'
env:
PROFILE_B64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
set -euo pipefail
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
echo "$PROFILE_B64" | base64 --decode > /tmp/profile.mobileprovision
if [ ! -s /tmp/profile.mobileprovision ]; then
echo "::error::Provisioning profile failed to decode"
exit 1
fi
# Xcode looks up profiles by UUID filename, so copy with the
# embedded UUID. Name is needed for ExportOptions.plist's
# provisioningProfiles map and PROVISIONING_PROFILE_SPECIFIER.
UUID=$(security cms -D -i /tmp/profile.mobileprovision | plutil -extract UUID raw -)
NAME=$(security cms -D -i /tmp/profile.mobileprovision | plutil -extract Name raw -)
cp /tmp/profile.mobileprovision \
"$HOME/Library/MobileDevice/Provisioning Profiles/${UUID}.mobileprovision"
echo "IOS_PROFILE_UUID=$UUID" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$NAME" >> "$GITHUB_ENV"
echo "Installed provisioning profile: $NAME ($UUID)"
- name: Install CocoaPods
if: steps.check.outputs.enabled == 'true'
run: |
if ! command -v pod &>/dev/null; then
brew install cocoapods
fi
pod --version
- name: Initialize iOS project
if: steps.check.outputs.enabled == 'true'
run: pnpm tauri ios init --ci
- name: Sync version + inject iOS signing
if: steps.check.outputs.enabled == 'true'
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
IOS_EXPORT_METHOD: app-store-connect
IOS_SIGNING_IDENTITY: ${{ vars.IOS_SIGNING_IDENTITY || 'Apple Distribution' }}
run: |
node scripts/sync-version.cjs
node scripts/inject-ios-signing.cjs
- name: Dump iOS build environment (diagnostic)
if: steps.check.outputs.enabled == 'true'
env:
IPHONEOS_DEPLOYMENT_TARGET: '16.0'
run: |
echo "── Shell env ──"
echo "IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-<unset>}"
echo "MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET:-<unset>}"
echo "SDKROOT=${SDKROOT:-<unset>}"
echo "── Xcode + SDK ──"
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
xcrun --sdk iphoneos --show-sdk-path
echo "── Generated project.yml deployment target ──"
grep -A2 'deploymentTarget' src-tauri/gen/apple/project.yml || true
echo "── Generated pbxproj IPHONEOS_DEPLOYMENT_TARGET ──"
grep -E 'IPHONEOS_DEPLOYMENT_TARGET|SDKROOT' \
src-tauri/gen/apple/forwardemail-desktop.xcodeproj/project.pbxproj | sort -u || true
echo "── Rust target cache state ──"
if [ -d src-tauri/target/aarch64-apple-ios ]; then
du -sh src-tauri/target/aarch64-apple-ios || true
echo "Existing libapp.a timestamps:"
find src-tauri/target/aarch64-apple-ios -name 'libapp.a' -exec ls -la {} \; 2>/dev/null || true
else
echo "(fresh — no src-tauri/target/aarch64-apple-ios yet)"
fi
- name: Build iOS (release, signed)
if: steps.check.outputs.enabled == 'true'
env:
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }}
# Propagate the deployment target into cargo -> tauri-utils ->
# swift-rs -> swiftc so the .swift.o files inside libapp.a aren't
# compiled against a target that still needs the Swift 5.6
# back-compat shim (libswiftCompatibility56.a). That shim is
# auto-linked when the deployment target is < iOS 15.4 — the
# version where Swift 5.6 first shipped in the OS — and Xcode 26's
# iOS 26.4 SDK no longer ships the library. Must be ≥ 15.4;
# 16.0 gives a clean margin.
IPHONEOS_DEPLOYMENT_TARGET: '16.0'
run: pnpm tauri ios build --ci --verbose --target aarch64 --export-method app-store-connect
- name: Rename IPA with version
if: steps.check.outputs.enabled == 'true'
run: |
set -euo pipefail
VERSION="${{ inputs.version || inputs.tag || github.ref_name }}"
VERSION="${VERSION#v}"
IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -type f | head -n 1)
if [ -z "$IPA" ]; then
echo "::error::No IPA produced under src-tauri/gen/apple/build"
find src-tauri/gen/apple/build -maxdepth 5 -type f | head -50
exit 1
fi
NEW="$(dirname "$IPA")/forwardemail-mail_${VERSION}_ios.ipa"
mv "$IPA" "$NEW"
echo "IPA_PATH=$NEW" >> "$GITHUB_ENV"
echo "Produced: $NEW"
- name: Repack IPA with unique CFBundleVersion (re-sign)
if: steps.check.outputs.enabled == 'true'
env:
# Tauri's `ios build` regenerates Info.plist from tauri.conf.json's
# single `version` field, mapping it to BOTH CFBundleShortVersionString
# and CFBundleVersion. Apple rejects re-uploads where CFBundleVersion
# matches any previously uploaded build for the same marketing
# version, so we override CFBundleVersion *after* the IPA is built:
# unzip → edit Info.plist → re-sign the .app → rezip.
#
# The build number must strictly increase across:
# - new workflow runs (github.run_number bumps)
# - re-runs of the same failed run ("Re-run failed jobs" keeps
# run_number constant but bumps run_attempt — must include both).
# Dotted CFBundleVersion is allowed; TestFlight orders numerically
# component-by-component (1234.2 > 1234.1 > 1234).
IOS_BUILD_NUMBER: ${{ github.run_number }}.${{ github.run_attempt }}
IOS_SIGNING_IDENTITY: ${{ vars.IOS_SIGNING_IDENTITY || 'Apple Distribution' }}
run: |
set -euo pipefail
if [ -z "${IPA_PATH:-}" ] || [ ! -f "$IPA_PATH" ]; then
echo "::error::IPA_PATH not set or file missing"
exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
unzip -q "$IPA_PATH" -d "$WORK"
APP=$(find "$WORK/Payload" -maxdepth 1 -name '*.app' -type d | head -n 1)
if [ -z "$APP" ] || [ ! -d "$APP" ]; then
echo "::error::No .app bundle found inside Payload/"
find "$WORK" -maxdepth 3 -type d
exit 1
fi
PLIST="$APP/Info.plist"
echo "── Before override ──"
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST"
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $IOS_BUILD_NUMBER" "$PLIST"
echo "── After override ──"
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST"
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$PLIST"
# Preserve the original entitlements so re-signing keeps capabilities
# (push, app groups, associated domains, etc.) — losing these breaks
# TestFlight processing or the runtime app on device.
ENTITLEMENTS="$WORK/entitlements.plist"
codesign -d --entitlements - --xml "$APP" > "$ENTITLEMENTS" 2>/dev/null \
|| codesign -d --entitlements :- "$APP" > "$ENTITLEMENTS"
if [ ! -s "$ENTITLEMENTS" ]; then
echo "::error::Failed to extract entitlements from $APP"
exit 1
fi
# Re-sign nested code (frameworks + dylibs) before the outer .app —
# codesign requires inner-first ordering. --force overwrites the
# existing signature; iOS distribution does not need --options runtime
# (that's macOS hardened-runtime only).
if [ -d "$APP/Frameworks" ]; then
find "$APP/Frameworks" -type d -name '*.framework' \
-exec codesign --force --sign "$IOS_SIGNING_IDENTITY" --timestamp {} \;
find "$APP/Frameworks" -type f -name '*.dylib' \
-exec codesign --force --sign "$IOS_SIGNING_IDENTITY" --timestamp {} \;
fi
codesign --force --sign "$IOS_SIGNING_IDENTITY" \
--entitlements "$ENTITLEMENTS" \
--timestamp \
"$APP"
codesign --verify --verbose=2 "$APP"
# Repackage. zip(1) with -X strips extra attributes that altool
# occasionally complains about; running from $WORK keeps the
# canonical Payload/<App>.app/ layout inside the IPA.
REPACK="$WORK/repacked.ipa"
(cd "$WORK" && zip -qry -X "$REPACK" Payload)
mv "$REPACK" "$IPA_PATH"
echo "Repacked IPA at: $IPA_PATH"
# SLSA build provenance for the final re-signed IPA — same binary that
# ships to TestFlight + GitHub release. Must run AFTER the repack, since
# the repack changes CFBundleVersion and re-signs, which changes the
# SHA. Users verify with `gh attestation verify <file> --owner forwardemail`.
- name: Attest iOS build provenance
if: steps.check.outputs.enabled == 'true'
uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2
with:
subject-path: ${{ env.IPA_PATH }}
- name: Upload to TestFlight (App Store Connect)
if: steps.check.outputs.enabled == 'true'
env:
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
set -euo pipefail
# altool auto-discovers keys in ~/.appstoreconnect/private_keys/
# by filename convention AuthKey_<KEY_ID>.p8
KEY_DIR="$HOME/.appstoreconnect/private_keys"
mkdir -p "$KEY_DIR"
echo "$ASC_KEY" > "$KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
chmod 600 "$KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER"
rm -f "$KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
- name: Resolve release ID (prefer published over draft)
if: steps.check.outputs.enabled == 'true'
id: resolve_release
uses: ./.github/actions/resolve-release
with:
tag: v${{ inputs.version || inputs.tag || github.ref_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload signed IPA to release
if: steps.check.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ steps.resolve_release.outputs.release_id }}
TAG: ${{ steps.resolve_release.outputs.tag }}
run: |
set -euo pipefail
if [ -z "${IPA_PATH:-}" ] || [ ! -f "$IPA_PATH" ]; then
echo "::error::IPA_PATH not set or file missing"
exit 1
fi
name=$(basename "$IPA_PATH")
echo "Uploading $name to release id=$RELEASE_ID (tag=$TAG)"
existing_id=$(gh api "repos/${GH_REPO}/releases/${RELEASE_ID}/assets" \
--jq ".[] | select(.name==\"$name\") | .id" | head -n 1)
if [ -n "$existing_id" ]; then
gh api -X DELETE "repos/${GH_REPO}/releases/assets/${existing_id}" || true
fi
gh api \
--method POST \
-H "Content-Type: application/octet-stream" \
"https://uploads.github.com/repos/${GH_REPO}/releases/${RELEASE_ID}/assets?name=${name}" \
--input "$IPA_PATH"
- name: Clean up signing artifacts
if: always()
run: |
rm -f /tmp/profile.mobileprovision
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles"/*.mobileprovision || true
rm -rf "$HOME/.appstoreconnect/private_keys" || true
security delete-keychain signing_temp.keychain-db 2>/dev/null || true