release/windows-microsoft-store
sh 391 lines 16.7 KB
Raw
1 #!/usr/bin/env bash
2 # build-ios.sh
3 #
4 # Full App Store pipeline for Siti AI (iOS):
5 # 1. Clean — rm -rf src-tauri/gen/apple
6 # 2. Init — cargo tauri ios init (local tauri CLI, prod iOS config)
7 # 3. Icons — pnpm gim
8 # 4. Override — copy gen-override/apple into gen/apple
9 # 5. Build — cargo tauri ios build (app-store-connect, datetime CFBundleVersion)
10 # 6. Fixplist — rewrite beta toolchain stamps → release values, re-sign
11 # 7. Upload — xcrun altool (App Store Connect submission)
12 #
13 # Every configurable value has a hardcoded default that can be overridden by
14 # setting the corresponding environment variable before invoking the script.
15 #
16 # Usage:
17 # ./scripts/build-ios.sh # full pipeline
18 # ./scripts/build-ios.sh clean # rm gen/apple only
19 # ./scripts/build-ios.sh init # tauri ios init only
20 # ./scripts/build-ios.sh icons # pnpm gim only
21 # ./scripts/build-ios.sh override # copy gen-override/apple only
22 # ./scripts/build-ios.sh build # build only (assumes init already done)
23 # ./scripts/build-ios.sh fixplist # patch beta toolchain stamps + re-sign
24 # ./scripts/build-ios.sh upload # upload only (assumes .ipa already built)
25 #
26 # make asios # full pipeline via Makefile
27 # make asios-clean
28 # make asios-init
29 # make asios-icons
30 # make asios-override
31 # make asios-build
32 # make asios-fixplist
33 # make asios-upload
34
35 set -euo pipefail
36
37 # ── Paths ─────────────────────────────────────────────────────────────────────
38
39 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
40 IOS_CONFIG="${REPO_ROOT}/src-tauri/tauri.prod-ios.conf.json"
41 GEN_APPLE="${REPO_ROOT}/src-tauri/gen/apple"
42 GEN_OVERRIDE="${REPO_ROOT}/src-tauri/gen-override/apple"
43
44 # Auto-load the untracked .env.appstore (signing identities + ASC creds) so
45 # `make asios` works without the caller exporting them first. See
46 # .env.appstore.example for the expected keys.
47 if [[ -f "${REPO_ROOT}/.env.appstore" ]]; then
48 set -a
49 # shellcheck disable=SC1091
50 . "${REPO_ROOT}/.env.appstore"
51 set +a
52 fi
53
54 # Xcode toolchain. App Store Connect rejects binaries built with a beta Xcode or
55 # beta SDK. `cargo tauri ios build` resolves the toolchain from the system
56 # xcode-select (it ignores DEVELOPER_DIR), so the fixplist step below rewrites
57 # the beta stamps instead — but a pinned release Xcode is still needed to derive
58 # the release values to stamp in.
59 # Override: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-ios.sh
60 pick_release_xcode() {
61 local active; active="$(xcode-select -p 2>/dev/null)"
62 if [[ -n "$active" && "$active" != *[Bb]eta* ]]; then
63 printf '%s\n' "$active"; return
64 fi
65 local app
66 for app in /Applications/Xcode*.app; do
67 case "$app" in *[Bb]eta*) continue ;; esac
68 [[ -d "$app/Contents/Developer" ]] && { printf '%s\n' "$app/Contents/Developer"; return; }
69 done
70 }
71 DEVELOPER_DIR="${DEVELOPER_DIR:-$(pick_release_xcode)}"
72 export DEVELOPER_DIR
73
74 # ── Overridable config ─────────────────────────────────────────────────────────
75 #
76 # Path to a local Tauri source checkout. The ios init command is run via
77 # `cargo run` against this manifest so the CLI version matches the workspace.
78 # Override: TAURI_CLI_MANIFEST=/path/to/tauri/Cargo.toml ./scripts/build-ios.sh
79 TAURI_CLI_MANIFEST="${TAURI_CLI_MANIFEST:-${HOME}/Repositories/tauri/Cargo.toml}"
80
81 # App Store Connect API credentials.
82 # ASC_API_KEY — Key ID shown in App Store Connect → Users & Access → Keys.
83 # ASC_ISSUER_ID — Issuer UUID on the same page.
84 # The private key (.p8) must be present at:
85 # ~/.appstoreconnect/private_keys/AuthKey_<ASC_API_KEY>.p8
86 ASC_API_KEY="${ASC_API_KEY:-L84N624YQH}"
87 ASC_ISSUER_ID="${ASC_ISSUER_ID:-b4e8d369-8b7d-4538-8435-643b73237575}"
88
89 # Beta-macOS submission workaround (non-sensitive; safe default).
90 #
91 # App Store Connect rejects binaries whose Info.plist reports a *beta*
92 # BuildMachineOSBuild (e.g. macOS 27.0 build 26A5353q → "Invalid Binary").
93 # The fixplist step rewrites that key to a shipping macOS build and re-signs
94 # the .app so the code-signature seal stays valid.
95 # RELEASE_OS_BUILD — a shipping macOS build number. Default 25A354 = macOS 26.0.
96 RELEASE_OS_BUILD="${RELEASE_OS_BUILD:-25A354}"
97
98 # Distribution identity used to re-sign after the fixplist patch
99 # (e.g. "Apple Distribution: <Your Org> (<TEAM_ID>)"). Required by fixplist only.
100 # SIGN_IDENTITY — env-only, not committed (set it in .env.appstore).
101
102 # App name — must match the productName in tauri.prod-ios.conf.json.
103 APP_NAME="${APP_NAME:-Siti AI}"
104
105 # Derived IPA path produced by `cargo tauri ios build`.
106 IPA_PATH="${REPO_ROOT}/src-tauri/gen/apple/build/arm64/${APP_NAME}.ipa"
107
108 # ── Helpers ───────────────────────────────────────────────────────────────────
109
110 step() { echo ""; echo "── $* ──────────────────────────────────────────────"; }
111 ok() { echo "✅ $*"; }
112 fail() { printf "❌ %b\n" "$*" >&2; exit 1; }
113
114 # Release toolchain stamps used by the fixplist step to rewrite a beta-built
115 # binary's Info.plist so App Store Connect reads it as a release-Xcode build.
116 # Auto-derived from the pinned release Xcode (DEVELOPER_DIR, from pick_release_xcode);
117 # every value is overridable via env.
118 # RELEASE_DTXCODE e.g. 2660 (Xcode 26.6 → "2660")
119 # RELEASE_XCODE_BUILD e.g. 17F113 (Xcode product build)
120 # RELEASE_SDK_VERSION e.g. 26.5
121 # RELEASE_SDK_NAME e.g. iphoneos26.5
122 # RELEASE_SDK_BUILD e.g. 23F81a (iPhoneOS SDK product build)
123 derive_release_toolchain() {
124 local dd="${DEVELOPER_DIR}"
125 if [[ -z "$dd" || ! -d "$dd" || "$dd" == *[Bb]eta* ]]; then
126 [[ -n "${RELEASE_DTXCODE:-}${RELEASE_XCODE_BUILD:-}" ]] \
127 || fail "No release Xcode found to derive release stamps.\n Install a release Xcode, or set RELEASE_DTXCODE / RELEASE_XCODE_BUILD / RELEASE_SDK_* by hand."
128 fi
129 local xv sv xver xbuild sdkver sdkbuild d
130 xv="$(DEVELOPER_DIR="$dd" xcodebuild -version 2>/dev/null)"
131 sv="$(DEVELOPER_DIR="$dd" xcodebuild -version -sdk iphoneos 2>/dev/null)"
132 xver="$(printf '%s\n' "$xv" | awk '/^Xcode/{print $2}')"
133 xbuild="$(printf '%s\n' "$xv" | awk '/Build version/{print $3}')"
134 sdkver="$(printf '%s\n' "$sv" | awk -F': ' '/SDKVersion/{print $2; exit}')"
135 sdkbuild="$(printf '%s\n' "$sv" | awk -F': ' '/ProductBuildVersion/{print $2; exit}')"
136 d="${xver//./}"; while [[ -n "$d" && ${#d} -lt 4 ]]; do d="${d}0"; done
137 REL_DTXCODE="${RELEASE_DTXCODE:-$d}"
138 REL_XCODE_BUILD="${RELEASE_XCODE_BUILD:-$xbuild}"
139 REL_SDK_VERSION="${RELEASE_SDK_VERSION:-$sdkver}"
140 REL_SDK_NAME="${RELEASE_SDK_NAME:-iphoneos${sdkver}}"
141 REL_SDK_BUILD="${RELEASE_SDK_BUILD:-$sdkbuild}"
142 [[ -n "$REL_DTXCODE" && -n "$REL_XCODE_BUILD" && -n "$REL_SDK_BUILD" ]] \
143 || fail "Could not determine release toolchain stamps from ${dd:-<unset>}."
144 }
145
146 # True when an Apple build number is a pre-release (beta) seed. Apple stamps
147 # seeds with a trailing lowercase letter (e.g. 26A5353q); shipping builds never
148 # carry one (e.g. 25A354). Used to decide whether the Info.plist actually needs
149 # patching, so the workaround is a no-op on a release macOS.
150 is_beta_build() {
151 [[ "${1:-}" =~ [a-z]$ ]]
152 }
153
154 # ── Steps ─────────────────────────────────────────────────────────────────────
155
156 do_clean() {
157 step "Clean"
158 rm -rf "${GEN_APPLE}"
159 ok "Removed ${GEN_APPLE}"
160 }
161
162 do_init() {
163 step "Init"
164
165 [[ -f "$IOS_CONFIG" ]] || fail "Config not found: $IOS_CONFIG"
166 [[ -f "$TAURI_CLI_MANIFEST" ]] || fail "Tauri CLI manifest not found: $TAURI_CLI_MANIFEST\n Set TAURI_CLI_MANIFEST to the path of your local tauri/Cargo.toml"
167
168 echo "📱 config → ${IOS_CONFIG}"
169 echo "🦀 cli → ${TAURI_CLI_MANIFEST}"
170 echo ""
171
172 cargo run --release \
173 --manifest-path "${TAURI_CLI_MANIFEST}" \
174 -p tauri-cli \
175 -- tauri ios init \
176 -c "${IOS_CONFIG}"
177
178 ok "Xcode project generated → ${GEN_APPLE}"
179 }
180
181 do_icons() {
182 step "Icons"
183 pnpm gim
184 ok "Icons generated"
185 }
186
187 do_override() {
188 step "Override"
189
190 if [[ -d "${GEN_OVERRIDE}" ]]; then
191 cp -r "${GEN_OVERRIDE}/." "${GEN_APPLE}/"
192 ok "Copied ${GEN_OVERRIDE} → ${GEN_APPLE}"
193 else
194 echo "ℹ️ No overrides found at ${GEN_OVERRIDE}, skipping."
195 fi
196 }
197
198 do_build() {
199 step "Build"
200
201 [[ -f "$IOS_CONFIG" ]] || fail "Config not found: $IOS_CONFIG"
202 [[ -d "$GEN_APPLE" ]] || fail "Xcode project not found at ${GEN_APPLE}\n Run 'init' step first."
203
204 # CFBundleVersion: YYYYMMDD.HHMM (UTC)
205 # Two period-separated integers → valid for Apple
206 # Monotonically increasing → satisfies App Store requirement
207 BUNDLE_VERSION="$(date -u +"%Y%m%d.%H%M")"
208 BUNDLE_VERSION_JSON="{\"bundle\":{\"iOS\":{\"bundleVersion\":\"${BUNDLE_VERSION}\"}}}"
209
210 echo "🏷 bundleVersion → ${BUNDLE_VERSION}"
211 echo "📦 app config → ${IOS_CONFIG}"
212 echo ""
213
214 # `cargo tauri ios build` resolves the toolchain from the system xcode-select
215 # (it ignores DEVELOPER_DIR), so on a beta-Xcode machine it builds with the
216 # beta Xcode. That is fine: the fixplist step rewrites the beta toolchain
217 # stamps (DTXcode, DTSDKBuild, …) to release values and re-signs, so the
218 # uploaded binary reads as a release-Xcode build. App Store Connect accepts it.
219 cargo tauri ios build \
220 --export-method app-store-connect \
221 --config "${IOS_CONFIG}" \
222 --config "${BUNDLE_VERSION_JSON}"
223
224 [[ -f "$IPA_PATH" ]] || fail "Build succeeded but .ipa not found at: $IPA_PATH"
225 ok "Build complete → ${IPA_PATH}"
226 }
227
228 do_fixplist() {
229 step "Fixplist (beta toolchain → release)"
230
231 [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'build' step first."
232
233 command -v /usr/libexec/PlistBuddy >/dev/null || fail "PlistBuddy not found"
234 : "${SIGN_IDENTITY:?set SIGN_IDENTITY (distribution identity for the re-sign)}"
235
236 WORK="$(mktemp -d)"
237 trap 'rm -rf "$WORK"' RETURN
238
239 unzip -q "$IPA_PATH" -d "$WORK"
240
241 local app_dir plist ent os_cur xcb_cur sdk_cur
242 app_dir="$(find "$WORK/Payload" -maxdepth 1 -name '*.app' -print -quit)"
243 [[ -n "$app_dir" && -d "$app_dir" ]] || fail "No .app found inside ${IPA_PATH}"
244 plist="$app_dir/Info.plist"
245 [[ -f "$plist" ]] || fail "Info.plist not found in $app_dir"
246
247 pl() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist" 2>/dev/null || echo ""; }
248 setpl() { /usr/libexec/PlistBuddy -c "Set :$1 $2" "$plist" 2>/dev/null \
249 || /usr/libexec/PlistBuddy -c "Add :$1 string $2" "$plist"; }
250
251 os_cur="$(pl BuildMachineOSBuild)" # set by the build *machine's* OS (beta macOS)
252 xcb_cur="$(pl DTXcodeBuild)" # set by the *Xcode* used (beta Xcode)
253 sdk_cur="$(pl DTSDKBuild)" # set by the *SDK* used (beta SDK)
254
255 # Patch when ANY toolchain stamp is a pre-release seed (trailing lowercase
256 # letter): a beta macOS (BuildMachineOSBuild), a beta Xcode (DTXcodeBuild) or a
257 # beta SDK (DTSDKBuild) all make App Store Connect reject the upload. Override
258 # with FORCE_FIXPLIST=1 to patch regardless, or SKIP_FIXPLIST=1 to never patch.
259 if [[ -n "${SKIP_FIXPLIST:-}" ]]; then
260 ok "SKIP_FIXPLIST set — leaving ${IPA_PATH} untouched (DTXcodeBuild=${xcb_cur:-<unset>})."
261 return 0
262 fi
263 if [[ -z "${FORCE_FIXPLIST:-}" ]] \
264 && ! is_beta_build "$os_cur" && ! is_beta_build "$xcb_cur" && ! is_beta_build "$sdk_cur"; then
265 ok "Toolchain stamps are already release seeds — no patch needed, leaving .ipa untouched."
266 return 0
267 fi
268
269 derive_release_toolchain
270 echo "📦 ipa → ${IPA_PATH}"
271 echo "🔏 identity → ${SIGN_IDENTITY}"
272 echo "🛠 xcode → ${REL_DTXCODE} / ${REL_XCODE_BUILD} sdk → ${REL_SDK_NAME} / ${REL_SDK_BUILD}"
273 echo ""
274
275 # Capture the entitlements the binary was actually signed with, so the
276 # re-sign reproduces them exactly (matches the embedded provisioning profile).
277 ent="$WORK/entitlements.plist"
278 if ! codesign -d --entitlements :"$ent" "$app_dir" 2>/dev/null || [[ ! -s "$ent" ]]; then
279 echo "ℹ️ Falling back to gen-override entitlements"
280 cp "${GEN_OVERRIDE}/sitiai_iOS/sitiai_iOS.entitlements" "$ent"
281 fi
282
283 # Rewrite every beta toolchain stamp to the release Xcode's values.
284 echo "🔁 BuildMachineOSBuild: ${os_cur:-<unset>} → ${RELEASE_OS_BUILD}"
285 echo "🔁 DTXcode/DTXcodeBuild: ${xcb_cur:-<unset>} → ${REL_DTXCODE}/${REL_XCODE_BUILD}"
286 echo "🔁 DTSDK*: ${sdk_cur:-<unset>} → ${REL_SDK_NAME}/${REL_SDK_BUILD}"
287 setpl BuildMachineOSBuild "${RELEASE_OS_BUILD}"
288 setpl DTXcode "${REL_DTXCODE}"
289 setpl DTXcodeBuild "${REL_XCODE_BUILD}"
290 setpl DTSDKName "${REL_SDK_NAME}"
291 setpl DTSDKBuild "${REL_SDK_BUILD}"
292 setpl DTPlatformBuild "${REL_SDK_BUILD}"
293 setpl DTPlatformVersion "${REL_SDK_VERSION}"
294
295 # Rewrite the Mach-O LC_BUILD_VERSION load command on every binary in the
296 # bundle. This is the critical step: App Store Connect's *review-stage*
297 # validation reads the `sdk` field baked into the executable's load command —
298 # NOT the Info.plist DTSDK* keys above — so a beta-SDK build (e.g. sdk 27.0)
299 # is accepted at upload, processes fine, then flags "Invalid Binary" only
300 # after it is submitted for review. Patching Info.plist alone does not fix it;
301 # vtool rewrites the binary itself. Each slice's `minos` is preserved; only
302 # the beta `sdk` is lowered to the release SDK (REL_SDK_VERSION). The re-sign
303 # below re-seals the modified Mach-O.
304 local macho
305 while IFS= read -r macho; do
306 [[ -z "$macho" ]] && continue
307 local minos_cur
308 minos_cur="$(xcrun vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
309 minos_cur="${minos_cur:-16.0}"
310 echo "🔁 Mach-O sdk → ${REL_SDK_VERSION} (minos ${minos_cur}): ${macho#$app_dir/}"
311 xcrun vtool -set-build-version ios "${minos_cur}" "${REL_SDK_VERSION}" \
312 -replace -output "${macho}.vt" "$macho" >/dev/null 2>&1 \
313 && mv "${macho}.vt" "$macho" \
314 || fail "vtool failed to patch LC_BUILD_VERSION on ${macho#$app_dir/}"
315 done < <(find "$app_dir" -type f -exec sh -c 'file "$1" | grep -q Mach-O' _ {} \; -print)
316
317 # Re-sign nested code first (deepest path → shallowest), then the .app itself.
318 # Editing Info.plist / the Mach-O load command invalidates the original
319 # signature; without this the upload is rejected for a broken seal.
320 local nested
321 while IFS= read -r nested; do
322 [[ -z "$nested" ]] && continue
323 echo "✍️ sign nested → ${nested#$app_dir/}"
324 codesign --force --timestamp=none --sign "$SIGN_IDENTITY" "$nested"
325 done < <(find "$app_dir" \( -name '*.framework' -o -name '*.dylib' -o -name '*.appex' \) -print | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
326
327 codesign --force --timestamp=none \
328 --sign "$SIGN_IDENTITY" \
329 --entitlements "$ent" \
330 "$app_dir"
331
332 codesign --verify --deep --strict "$app_dir" \
333 || fail "Re-signed app failed signature verification"
334 echo "🔁 now: DTXcodeBuild=$(pl DTXcodeBuild) DTSDKBuild=$(pl DTSDKBuild) BuildMachineOSBuild=$(pl BuildMachineOSBuild)"
335
336 # Repack the .ipa in place (zip from the extraction root so Payload/ is at top).
337 rm -f "$IPA_PATH"
338 ( cd "$WORK" && zip -qry "$IPA_PATH" Payload )
339 [[ -f "$IPA_PATH" ]] || fail "Repack failed: $IPA_PATH"
340
341 ok "Patched + re-signed → ${IPA_PATH}"
342 }
343
344 do_upload() {
345 step "Upload"
346
347 [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'build' step first."
348
349 echo "📤 file → ${IPA_PATH}"
350 echo "🔑 apiKey → ${ASC_API_KEY}"
351 echo "🏢 issuer → ${ASC_ISSUER_ID}"
352 echo ""
353
354 xcrun altool \
355 --upload-app \
356 --type ios \
357 --file "${IPA_PATH}" \
358 --apiKey "${ASC_API_KEY}" \
359 --apiIssuer "${ASC_ISSUER_ID}"
360
361 ok "Uploaded to App Store Connect"
362 }
363
364 # ── Entry point ───────────────────────────────────────────────────────────────
365
366 STEP="${1:-all}"
367
368 case "$STEP" in
369 clean) do_clean ;;
370 init) do_init ;;
371 icons) do_icons ;;
372 override) do_override ;;
373 build) do_build ;;
374 fixplist) do_fixplist ;;
375 upload) do_upload ;;
376 all)
377 do_clean
378 do_init
379 do_icons
380 do_override
381 do_build
382 do_fixplist
383 do_upload
384 echo ""
385 echo "🎉 Pipeline complete: built and uploaded ${APP_NAME} to App Store Connect."
386 ;;
387 *)
388 echo "Usage: $0 [clean|init|icons|override|build|fixplist|upload|all]" >&2
389 exit 1
390 ;;
391 esac