Raw
1 #!/usr/bin/env bash
2 # build-visionos.sh
3 #
4 # Full App Store pipeline for the native SitiVision visionOS app (xcode/):
5 # 1. Generate — xcodegen generate (SitiVision.xcodeproj is not checked in)
6 # 2. Archive — xcodebuild archive (automatic signing, Splitfire AB team)
7 # 3. Export — xcodebuild -exportArchive (method app-store-connect → .ipa)
8 # 4. Fixplist — rewrite the beta host-OS stamp (BuildMachineOSBuild) + Mach-O
9 # LC_BUILD_VERSION, then re-sign — see do_fixplist for why
10 # 5. Upload — xcrun altool (TestFlight / App Store Connect submission)
11 #
12 # Unlike build-ios.sh (Tauri + cargo-tauri), SitiVision is a plain XcodeGen
13 # project, so this pipeline is xcodebuild/xcodegen only — no cargo/pnpm steps.
14 # The fixplist step follows the same workaround as the sibling splitfire repo's
15 # Scripts/build_karokowe_appstore.sh (native tvOS/visionOS Xcode projects) and
16 # this repo's own build-ios.sh (Tauri iOS) — see do_fixplist for the mechanism.
17 #
18 # Every configurable value has a hardcoded default that can be overridden by
19 # setting the corresponding environment variable before invoking the script.
20 #
21 # Usage:
22 # ./scripts/build-visionos.sh # full pipeline
23 # ./scripts/build-visionos.sh generate # xcodegen generate only
24 # ./scripts/build-visionos.sh archive # archive only (assumes project generated)
25 # ./scripts/build-visionos.sh export # export .ipa only (assumes archive already built)
26 # ./scripts/build-visionos.sh fixplist # patch beta host-OS stamp + re-sign
27 # ./scripts/build-visionos.sh upload # upload only (assumes .ipa already exported)
28 #
29 # make asvision # full pipeline via Makefile
30 # make asvision-generate
31 # make asvision-archive
32 # make asvision-export
33 # make asvision-fixplist
34 # make asvision-upload
35
36 set -euo pipefail
37
38 # ── Paths ─────────────────────────────────────────────────────────────────────
39
40 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
41 XCODE_DIR="${REPO_ROOT}/xcode"
42 PROJECT="${XCODE_DIR}/SitiVision.xcodeproj"
43 BUILD_DIR="${XCODE_DIR}/build"
44 ARCHIVE_PATH="${BUILD_DIR}/SitiVision.xcarchive"
45 EXPORT_DIR="${BUILD_DIR}/export"
46 EXPORT_OPTIONS="${BUILD_DIR}/ExportOptions.plist"
47 IPA_PATH="${EXPORT_DIR}/SitiVision.ipa"
48
49 # Auto-load the untracked .env.appstore (signing identities + ASC creds) so
50 # `make asvision` works without the caller exporting them first. See
51 # .env.appstore.example for the expected keys.
52 if [[ -f "${REPO_ROOT}/.env.appstore" ]]; then
53 set -a
54 # shellcheck disable=SC1091
55 . "${REPO_ROOT}/.env.appstore"
56 set +a
57 fi
58
59 # Xcode toolchain. App Store Connect rejects binaries built with a beta Xcode or
60 # beta SDK, so pin a release Xcode here regardless of the global `xcode-select`.
61 # Override: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-visionos.sh
62 pick_release_xcode() {
63 local active; active="$(xcode-select -p 2>/dev/null)"
64 if [[ -n "$active" && "$active" != *[Bb]eta* ]]; then
65 printf '%s\n' "$active"; return
66 fi
67 local app
68 for app in /Applications/Xcode*.app; do
69 case "$app" in *[Bb]eta*) continue ;; esac
70 [[ -d "$app/Contents/Developer" ]] && { printf '%s\n' "$app/Contents/Developer"; return; }
71 done
72 }
73 DEVELOPER_DIR="${DEVELOPER_DIR:-$(pick_release_xcode)}"
74 export DEVELOPER_DIR
75
76 # ── Overridable config ─────────────────────────────────────────────────────────
77
78 # Apple Developer team (Splitfire AB — same team as the Tauri app's iOS/macOS
79 # builds; see project.yml's DEVELOPMENT_TEAM and ../src-tauri/tauri.conf.json).
80 TEAM_ID="${TEAM_ID:-2TQF86ZACD}"
81
82 # App Store Connect API credentials. Used both for -allowProvisioningUpdates
83 # (so archive/export can fetch/create the distribution profile headlessly,
84 # without a signed-in Xcode account) and for the altool upload.
85 # ASC_API_KEY — Key ID shown in App Store Connect → Users & Access → Keys.
86 # ASC_ISSUER_ID — Issuer UUID on the same page.
87 # The private key (.p8) must be present at:
88 # ~/.appstoreconnect/private_keys/AuthKey_<ASC_API_KEY>.p8 (Xcode's default), or
89 # ~/private_keys/AuthKey_<ASC_API_KEY>.p8 (ASC_PRIVATE_KEY_PATH override)
90 ASC_API_KEY="${ASC_API_KEY:-L84N624YQH}"
91 ASC_ISSUER_ID="${ASC_ISSUER_ID:-b4e8d369-8b7d-4538-8435-643b73237575}"
92 ASC_PRIVATE_KEY_PATH="${ASC_PRIVATE_KEY_PATH:-${HOME}/private_keys/AuthKey_${ASC_API_KEY}.p8}"
93
94 # Re-sign identity used by the fixplist step (distribution cert for the same
95 # team as TEAM_ID). Not secret — safe to commit once known.
96 SIGN_IDENTITY="${SIGN_IDENTITY:-Apple Distribution: Splitfire AB (2TQF86ZACD)}"
97
98 # A shipping macOS build number to stamp for BuildMachineOSBuild during
99 # fixplist (matches the constant already used by build-ios.sh and the
100 # sibling splitfire repo's Scripts/build_karokowe_appstore.sh).
101 RELEASE_OS_BUILD="${RELEASE_OS_BUILD:-25A354}"
102
103 # ── Helpers ───────────────────────────────────────────────────────────────────
104
105 step() { echo ""; echo "── $* ──────────────────────────────────────────────"; }
106 ok() { echo "✅ $*"; }
107 fail() { printf "❌ %b\n" "$*" >&2; exit 1; }
108
109 # True when an Apple build number is a pre-release (beta) seed. Apple stamps
110 # seeds with a trailing lowercase letter (e.g. 26A5353q); shipping builds
111 # never carry one (e.g. 25A354).
112 is_beta_build() {
113 [[ "${1:-}" =~ [a-z]$ ]]
114 }
115
116 require_release_xcode() {
117 [[ -n "${DEVELOPER_DIR}" && -d "${DEVELOPER_DIR}" ]] \
118 || fail "No release Xcode found.\n Install a release Xcode, or set DEVELOPER_DIR to its Contents/Developer dir."
119 case "${DEVELOPER_DIR}" in
120 *[Bb]eta*)
121 fail "DEVELOPER_DIR points at a beta Xcode:\n ${DEVELOPER_DIR}\n App Store Connect rejects beta-built binaries. Point DEVELOPER_DIR at a release Xcode." ;;
122 esac
123 echo "🛠 Xcode → $(xcodebuild -version 2>/dev/null | head -1) [${DEVELOPER_DIR}]"
124 }
125
126 require_asc_key() {
127 [[ -f "${ASC_PRIVATE_KEY_PATH}" ]] \
128 || fail "ASC API private key not found: ${ASC_PRIVATE_KEY_PATH}\n Download it from App Store Connect → Users & Access → Integrations → App Store Connect API (once — Apple won't let you re-download), or set ASC_PRIVATE_KEY_PATH."
129 }
130
131 # Release toolchain stamps used by fixplist to rewrite a bundle's Info.plist
132 # so App Store Connect reads it as a release-Xcode/SDK build. Derived from the
133 # pinned release Xcode (DEVELOPER_DIR); every value is overridable via env.
134 derive_release_toolchain() {
135 local xv sv xver xbuild sdkver sdkbuild d
136 xv="$(xcodebuild -version 2>/dev/null)"
137 sv="$(xcodebuild -version -sdk xros 2>/dev/null)"
138 xver="$(printf '%s\n' "$xv" | awk '/^Xcode/{print $2}')"
139 xbuild="$(printf '%s\n' "$xv" | awk '/Build version/{print $3}')"
140 sdkver="$(printf '%s\n' "$sv" | awk -F': ' '/SDKVersion/{print $2; exit}')"
141 sdkbuild="$(printf '%s\n' "$sv" | awk -F': ' '/ProductBuildVersion/{print $2; exit}')"
142 d="${xver//./}"; while [[ -n "$d" && ${#d} -lt 4 ]]; do d="${d}0"; done
143 REL_DTXCODE="${RELEASE_DTXCODE:-$d}"
144 REL_XCODE_BUILD="${RELEASE_XCODE_BUILD:-$xbuild}"
145 REL_SDK_VERSION="${RELEASE_SDK_VERSION:-$sdkver}"
146 REL_SDK_NAME="${RELEASE_SDK_NAME:-xros${sdkver}}"
147 REL_SDK_BUILD="${RELEASE_SDK_BUILD:-$sdkbuild}"
148 [[ -n "$REL_DTXCODE" && -n "$REL_XCODE_BUILD" && -n "$REL_SDK_BUILD" ]] \
149 || fail "Could not determine release toolchain stamps from ${DEVELOPER_DIR}."
150 }
151
152 patch_bundle_plist() {
153 local plist="$1"
154 pl() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist" 2>/dev/null || echo ""; }
155 setpl() { /usr/libexec/PlistBuddy -c "Set :$1 $2" "$plist" 2>/dev/null \
156 || /usr/libexec/PlistBuddy -c "Add :$1 string $2" "$plist"; }
157 setpl BuildMachineOSBuild "${RELEASE_OS_BUILD}"
158 setpl DTXcode "${REL_DTXCODE}"; setpl DTXcodeBuild "${REL_XCODE_BUILD}"
159 setpl DTSDKName "${REL_SDK_NAME}"; setpl DTSDKBuild "${REL_SDK_BUILD}"
160 setpl DTPlatformBuild "${REL_SDK_BUILD}"; setpl DTPlatformVersion "${REL_SDK_VERSION}"
161 }
162
163 # ── Steps ─────────────────────────────────────────────────────────────────────
164
165 do_generate() {
166 step "Generate"
167
168 command -v xcodegen >/dev/null || fail "xcodegen not found.\n Install with: brew install xcodegen"
169
170 ( cd "${XCODE_DIR}" && xcodegen generate )
171 [[ -d "$PROJECT" ]] || fail "xcodegen ran but ${PROJECT} was not created"
172 ok "Xcode project generated → ${PROJECT}"
173 }
174
175 do_archive() {
176 step "Archive"
177
178 require_release_xcode
179 require_asc_key
180 [[ -d "$PROJECT" ]] || fail "Xcode project not found at ${PROJECT}\n Run 'generate' step first."
181
182 # CURRENT_PROJECT_VERSION: unix-seconds build number.
183 # Strictly increasing across builds → satisfies App Store Connect's
184 # "each build must have a higher build number than the last" requirement.
185 BUILD_NUMBER="$(date -u +"%s")"
186
187 mkdir -p "${BUILD_DIR}"
188 rm -rf "${ARCHIVE_PATH}"
189
190 echo "🏷 buildNumber → ${BUILD_NUMBER}"
191 echo "👥 team → ${TEAM_ID}"
192 echo ""
193
194 xcodebuild archive \
195 -project "${PROJECT}" \
196 -scheme SitiVision \
197 -destination "generic/platform=visionOS" \
198 -archivePath "${ARCHIVE_PATH}" \
199 -configuration Release \
200 -allowProvisioningUpdates \
201 -authenticationKeyPath "${ASC_PRIVATE_KEY_PATH}" \
202 -authenticationKeyID "${ASC_API_KEY}" \
203 -authenticationKeyIssuerID "${ASC_ISSUER_ID}" \
204 CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \
205 DEVELOPMENT_TEAM="${TEAM_ID}"
206
207 [[ -d "$ARCHIVE_PATH" ]] || fail "Archive succeeded but not found at: $ARCHIVE_PATH"
208 ok "Archive complete → ${ARCHIVE_PATH}"
209 }
210
211 do_export() {
212 step "Export"
213
214 require_release_xcode
215 require_asc_key
216 [[ -d "$ARCHIVE_PATH" ]] || fail "Archive not found: ${ARCHIVE_PATH}\n Run 'archive' step first."
217
218 # exportOptionsPlist: automatic signing re-resolves to a *distribution*
219 # certificate + App Store profile for method app-store-connect, even though
220 # the archive step itself signs with a development identity (expected —
221 # Xcode always archives with the dev cert; export re-signs per method).
222 cat > "${EXPORT_OPTIONS}" <<PLIST
223 <?xml version="1.0" encoding="UTF-8"?>
224 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
225 <plist version="1.0">
226 <dict>
227 <key>method</key>
228 <string>app-store-connect</string>
229 <key>teamID</key>
230 <string>${TEAM_ID}</string>
231 <key>signingStyle</key>
232 <string>automatic</string>
233 <key>destination</key>
234 <string>export</string>
235 </dict>
236 </plist>
237 PLIST
238
239 rm -rf "${EXPORT_DIR}"
240
241 xcodebuild -exportArchive \
242 -archivePath "${ARCHIVE_PATH}" \
243 -exportPath "${EXPORT_DIR}" \
244 -exportOptionsPlist "${EXPORT_OPTIONS}" \
245 -allowProvisioningUpdates \
246 -authenticationKeyPath "${ASC_PRIVATE_KEY_PATH}" \
247 -authenticationKeyID "${ASC_API_KEY}" \
248 -authenticationKeyIssuerID "${ASC_ISSUER_ID}"
249
250 [[ -f "$IPA_PATH" ]] || fail "Export succeeded but .ipa not found at: $IPA_PATH"
251 ok "Export complete → ${IPA_PATH}"
252 }
253
254 # Why this is needed even with a release Xcode pinned via DEVELOPER_DIR:
255 # xcodebuild fully respects DEVELOPER_DIR, so DTXcode/DTXcodeBuild/DTSDKBuild
256 # and every Mach-O's embedded LC_BUILD_VERSION sdk field all come out
257 # clean/release automatically. But `BuildMachineOSBuild` reflects the literal
258 # host macOS build — this Mac itself, regardless of which Xcode.app performs
259 # the build. When the host OS is a beta seed (a build number with a trailing
260 # lowercase letter, e.g. `26A5353q`), that field alone is enough to make App
261 # Store Connect's REVIEW-SUBMISSION validation — a separate, stricter pass
262 # than TestFlight build processing — reject the binary as "Invalid Binary"
263 # with no further detail, even though TestFlight processing itself reports
264 # the build as valid. Mirrors build-ios.sh's fixplist step and the sibling
265 # splitfire repo's Scripts/build_karokowe_appstore.sh, adapted for a plain
266 # xcodebuild archive/export instead of `cargo tauri ios build`.
267 do_fixplist() {
268 step "Fixplist (beta host OS → release)"
269
270 [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'export' step first."
271 command -v /usr/libexec/PlistBuddy >/dev/null || fail "PlistBuddy not found"
272
273 local work app_dir os_cur
274 work="$(mktemp -d)"
275 trap 'rm -rf "$work"' RETURN
276
277 unzip -q "$IPA_PATH" -d "$work"
278 app_dir="$(find "$work/Payload" -maxdepth 1 -name '*.app' -print -quit)"
279 [[ -n "$app_dir" && -d "$app_dir" ]] || fail "No .app found inside ${IPA_PATH}"
280
281 os_cur="$(/usr/libexec/PlistBuddy -c 'Print :BuildMachineOSBuild' "$app_dir/Info.plist" 2>/dev/null || echo "")"
282 if [[ -z "${FORCE_FIXPLIST:-}" ]] && ! is_beta_build "$os_cur"; then
283 ok "BuildMachineOSBuild (${os_cur}) is already a release seed — no patch needed."
284 return 0
285 fi
286
287 require_release_xcode
288 derive_release_toolchain
289 echo "🛠 xcode → ${REL_DTXCODE}/${REL_XCODE_BUILD} sdk → ${REL_SDK_NAME}/${REL_SDK_BUILD}"
290 echo "🔁 BuildMachineOSBuild ${os_cur:-<unset>} → ${RELEASE_OS_BUILD}"
291 echo "🔏 identity → ${SIGN_IDENTITY}"
292 echo ""
293
294 # Patch every bundle's Info.plist: the main .app and any nested extensions.
295 local bundles=("$app_dir")
296 while IFS= read -r appex; do bundles+=("$appex"); done \
297 < <(find "$app_dir" -name '*.appex' -type d)
298 local bundle
299 for bundle in "${bundles[@]}"; do
300 [[ -f "$bundle/Info.plist" ]] && patch_bundle_plist "$bundle/Info.plist"
301 done
302
303 # Critical: App Store Connect's review-stage validation reads the `sdk`
304 # baked into each executable's Mach-O LC_BUILD_VERSION, not just the
305 # Info.plist DTSDK* keys. Patch every Mach-O in the bundle; minos is
306 # preserved, only the sdk is (re)set to the release SDK.
307 local macho minos_cur
308 while IFS= read -r macho; do
309 [[ -z "$macho" ]] && continue
310 minos_cur="$(xcrun vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
311 minos_cur="${minos_cur:-2.0}"
312 echo "🔁 Mach-O sdk → ${REL_SDK_VERSION} (minos ${minos_cur}): ${macho#"$app_dir"/}"
313 xcrun vtool -set-build-version visionos "${minos_cur}" "${REL_SDK_VERSION}" \
314 -replace -output "${macho}.vt" "$macho" >/dev/null 2>&1 \
315 && mv "${macho}.vt" "$macho" \
316 || fail "vtool failed to patch LC_BUILD_VERSION on ${macho#"$app_dir"/}"
317 done < <(find "$app_dir" -type f -exec sh -c 'file "$1" | grep -q Mach-O' _ {} \; -print)
318
319 # Re-sign nested code deepest-first, then the .app last — editing the
320 # plist/Mach-O invalidates the original signature.
321 local nested ent
322 while IFS= read -r nested; do
323 [[ -z "$nested" ]] && continue
324 ent="${work}/$(basename "$nested" | tr '/' '_').entitlements"
325 echo "✍️ sign nested → ${nested#"$app_dir"/}"
326 if codesign -d --entitlements :"$ent" "$nested" 2>/dev/null && [[ -s "$ent" ]]; then
327 codesign --force --timestamp=none --sign "$SIGN_IDENTITY" --entitlements "$ent" "$nested"
328 else
329 codesign --force --timestamp=none --sign "$SIGN_IDENTITY" "$nested"
330 fi
331 done < <(find "$app_dir" \( -name '*.framework' -o -name '*.dylib' -o -name '*.appex' \) -print \
332 | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
333
334 ent="${work}/app.entitlements"
335 if codesign -d --entitlements :"$ent" "$app_dir" 2>/dev/null && [[ -s "$ent" ]]; then
336 codesign --force --timestamp=none --sign "$SIGN_IDENTITY" --entitlements "$ent" "$app_dir"
337 else
338 codesign --force --timestamp=none --sign "$SIGN_IDENTITY" "$app_dir"
339 fi
340 codesign --verify --deep --strict "$app_dir" || fail "Re-signed app failed signature verification"
341 echo "🔁 now: DTXcodeBuild=$(/usr/libexec/PlistBuddy -c 'Print :DTXcodeBuild' "$app_dir/Info.plist" 2>/dev/null) BuildMachineOSBuild=$(/usr/libexec/PlistBuddy -c 'Print :BuildMachineOSBuild' "$app_dir/Info.plist" 2>/dev/null)"
342
343 rm -f "$IPA_PATH"
344 ( cd "$work" && zip -qry "$IPA_PATH" Payload )
345 [[ -f "$IPA_PATH" ]] || fail "Repack failed: $IPA_PATH"
346
347 ok "Patched + re-signed → ${IPA_PATH}"
348 }
349
350 do_upload() {
351 step "Upload"
352
353 [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'export' step first."
354
355 echo "📤 file → ${IPA_PATH}"
356 echo "🔑 apiKey → ${ASC_API_KEY}"
357 echo "🏢 issuer → ${ASC_ISSUER_ID}"
358 echo ""
359
360 xcrun altool \
361 --upload-app \
362 --type visionos \
363 --file "${IPA_PATH}" \
364 --apiKey "${ASC_API_KEY}" \
365 --apiIssuer "${ASC_ISSUER_ID}"
366
367 ok "Uploaded to App Store Connect"
368 }
369
370 # ── Entry point ───────────────────────────────────────────────────────────────
371
372 STEP="${1:-all}"
373
374 case "$STEP" in
375 generate) do_generate ;;
376 archive) do_archive ;;
377 export) do_export ;;
378 fixplist) do_fixplist ;;
379 upload) do_upload ;;
380 all)
381 do_generate
382 do_archive
383 do_export
384 do_fixplist
385 do_upload
386 echo ""
387 echo "🎉 Pipeline complete: built and uploaded SitiVision to App Store Connect."
388 ;;
389 *)
390 echo "Usage: $0 [generate|archive|export|fixplist|upload|all]" >&2
391 exit 1
392 ;;
393 esac