Raw
1 #!/usr/bin/env bash
2 # build-appstore.sh
3 #
4 # Full macOS App Store pipeline for Siti AI:
5 # 1. Build — cargo tauri build (universal, App Store config, datetime CFBundleVersion)
6 # 2. Sign — xcrun productbuild (installer package signing)
7 # 3. Upload — xcrun altool (App Store Connect submission)
8 #
9 # Every configurable value has a hardcoded default that can be overridden by
10 # setting the corresponding environment variable before invoking the script.
11 #
12 # Usage:
13 # ./scripts/build-appstore.sh # full pipeline
14 # ./scripts/build-appstore.sh build # build only
15 # ./scripts/build-appstore.sh sign # sign only (assumes .app already built)
16 # ./scripts/build-appstore.sh upload # upload only (assumes .pkg already signed)
17 #
18 # make asmacos # full pipeline via Makefile
19 # make asmacos-build
20 # make asmacos-sign
21 # make asmacos-upload
22
23 set -euo pipefail
24
25 # ── Paths ─────────────────────────────────────────────────────────────────────
26
27 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
28 APPSTORE_CONFIG="${REPO_ROOT}/src-tauri/tauri.prod-macos-appstore.conf.json"
29
30 # Auto-load the untracked .env.appstore (signing identities + ASC creds) so
31 # `make asmacos` works without the caller exporting them first. See
32 # .env.appstore.example for the expected keys.
33 if [[ -f "${REPO_ROOT}/.env.appstore" ]]; then
34 set -a
35 # shellcheck disable=SC1091
36 . "${REPO_ROOT}/.env.appstore"
37 set +a
38 fi
39
40 # Xcode toolchain. App Store Connect rejects binaries built with a beta Xcode or
41 # beta SDK, so pin a release Xcode here regardless of the global `xcode-select`.
42 # Default: the active xcode-select if it is a release, otherwise the newest
43 # non-beta Xcode under /Applications.
44 # Override: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-appstore.sh
45 pick_release_xcode() {
46 local active; active="$(xcode-select -p 2>/dev/null)"
47 if [[ -n "$active" && "$active" != *[Bb]eta* ]]; then
48 printf '%s\n' "$active"; return
49 fi
50 local app
51 for app in /Applications/Xcode*.app; do
52 case "$app" in *[Bb]eta*) continue ;; esac
53 [[ -d "$app/Contents/Developer" ]] && { printf '%s\n' "$app/Contents/Developer"; return; }
54 done
55 }
56 DEVELOPER_DIR="${DEVELOPER_DIR:-$(pick_release_xcode)}"
57 export DEVELOPER_DIR
58
59 # ── Overridable config ─────────────────────────────────────────────────────────
60 #
61 # Installer signing identity: SHA-1 fingerprint of the "3rd Party Mac Developer
62 # Installer" certificate in your keychain. This is NOT the same as the app
63 # signing identity declared in tauri.prod-macos-appstore.conf.json
64 # (bundle.macOS.signingIdentity = D865610A9D550A397A90E07EB476118EDC48F829),
65 # which Tauri uses to codesign the .app bundle itself. productbuild requires a
66 # separate "3rd Party Mac Developer Installer: …" cert. Not secret — safe to
67 # commit once you know the fingerprint.
68 #
69 # Override: SIGNING_IDENTITY=<fingerprint> ./scripts/build-appstore.sh
70 SIGNING_IDENTITY="${SIGNING_IDENTITY:-}"
71
72 # App Store Connect API credentials.
73 # ASC_API_KEY — Key ID shown in App Store Connect → Users & Access → Keys.
74 # ASC_ISSUER_ID — Issuer UUID on the same page.
75 # The private key (.p8) must be present at:
76 # ~/.appstoreconnect/private_keys/AuthKey_<ASC_API_KEY>.p8
77 ASC_API_KEY="${ASC_API_KEY:-L84N624YQH}"
78 ASC_ISSUER_ID="${ASC_ISSUER_ID:-b4e8d369-8b7d-4538-8435-643b73237575}"
79
80 # App name — must match the productName in tauri.prod-macos-appstore.conf.json
81 # and therefore the .app bundle produced by tauri build.
82 APP_NAME="${APP_NAME:-Siti AI}"
83
84 # Derived paths — quoted to handle the space in "Siti AI".
85 APP_PATH="${REPO_ROOT}/src-tauri/target/universal-apple-darwin/release/bundle/macos/${APP_NAME}.app"
86 PKG_PATH="${REPO_ROOT}/${APP_NAME}.pkg"
87
88 # ── Helpers ───────────────────────────────────────────────────────────────────
89
90 step() { echo ""; echo "── $* ──────────────────────────────────────────────"; }
91 ok() { echo "✅ $*"; }
92 fail() { printf "❌ %b\n" "$*" >&2; exit 1; }
93
94 # Refuse to build with a beta toolchain — App Store Connect rejects beta-built
95 # binaries (the cause of an "Invalid Binary" after upload). DEVELOPER_DIR is
96 # pinned to a release Xcode above; `cargo tauri build` resolves the macOS
97 # linker/SDK via xcrun, which honors DEVELOPER_DIR.
98 require_release_xcode() {
99 [[ -n "${DEVELOPER_DIR}" && -d "${DEVELOPER_DIR}" ]] \
100 || fail "No release Xcode found.\n Install a release Xcode, or set DEVELOPER_DIR to its Contents/Developer dir."
101 case "${DEVELOPER_DIR}" in
102 *[Bb]eta*)
103 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." ;;
104 esac
105 echo "🛠 Xcode → $(xcodebuild -version 2>/dev/null | head -1) [${DEVELOPER_DIR}]"
106 }
107
108 # A genuinely full disk during a large release build writes truncated/corrupt
109 # proc-macro dylibs, which fail with the same "can't find crate" errors as the
110 # beta-macOS strip bug — easy to conflate. Guard against it up front.
111 preflight_disk() {
112 local free_gb; free_gb=$(df -g / | awk 'NR==2 {print $4}')
113 [[ "${free_gb:-0}" -ge 20 ]] || fail "Only ${free_gb} GB free on / — a universal release build needs ~20 GB+. Free space and retry."
114 }
115
116 # ── Steps ─────────────────────────────────────────────────────────────────────
117
118 do_build() {
119 step "1/3 Build"
120
121 require_release_xcode
122 preflight_disk
123 [[ -f "$APPSTORE_CONFIG" ]] || fail "Config not found: $APPSTORE_CONFIG"
124
125 # CFBundleVersion: YYYYMMDD.HHMM (UTC)
126 # Two period-separated integers → valid for Apple
127 # Monotonically increasing → satisfies App Store requirement
128 BUNDLE_VERSION="$(date -u +"%Y%m%d.%H%M")"
129 BUNDLE_VERSION_JSON="{\"bundle\":{\"macOS\":{\"bundleVersion\":\"${BUNDLE_VERSION}\"}}}"
130
131 echo "🏷 bundleVersion → ${BUNDLE_VERSION}"
132 echo "📦 app config → ${APPSTORE_CONFIG}"
133 echo ""
134
135 # Tauri merges multiple --config flags in order via json_patch::merge.
136 # The inline JSON overrides only bundleVersion; all other keys come from
137 # tauri.prod-macos-appstore.conf.json. The config file is never mutated.
138 cargo tauri build \
139 --bundles app \
140 --target universal-apple-darwin \
141 --config "${APPSTORE_CONFIG}" \
142 --config "${BUNDLE_VERSION_JSON}"
143
144 [[ -d "$APP_PATH" ]] || fail "Build succeeded but .app not found at: $APP_PATH"
145 ok "Build complete → ${APP_PATH}"
146 }
147
148 do_sign() {
149 step "2/3 Sign"
150
151 [[ -d "$APP_PATH" ]] || fail ".app not found: ${APP_PATH}\n Run 'build' step first."
152
153 if [[ -z "${SIGNING_IDENTITY}" ]]; then
154 fail "SIGNING_IDENTITY is not set.\n Export the SHA-1 fingerprint of your '3rd Party Mac Developer Installer' certificate:\n export SIGNING_IDENTITY=<fingerprint>"
155 fi
156
157 echo "🔏 identity → ${SIGNING_IDENTITY}"
158 echo "📂 component → ${APP_PATH}"
159 echo "📦 output → ${PKG_PATH}"
160 echo ""
161
162 xcrun productbuild \
163 --sign "${SIGNING_IDENTITY}" \
164 --component "${APP_PATH}" /Applications \
165 "${PKG_PATH}"
166
167 [[ -f "$PKG_PATH" ]] || fail "productbuild succeeded but .pkg not found at: $PKG_PATH"
168 ok "Signed → ${PKG_PATH}"
169 }
170
171 do_upload() {
172 step "3/3 Upload"
173
174 [[ -f "$PKG_PATH" ]] || fail ".pkg not found: ${PKG_PATH}\n Run 'sign' step first."
175
176 echo "📤 file → ${PKG_PATH}"
177 echo "🔑 apiKey → ${ASC_API_KEY}"
178 echo "🏢 issuer → ${ASC_ISSUER_ID}"
179 echo ""
180
181 xcrun altool \
182 --upload-app \
183 --type macos \
184 --file "${PKG_PATH}" \
185 --apiKey "${ASC_API_KEY}" \
186 --apiIssuer "${ASC_ISSUER_ID}"
187
188 ok "Uploaded to App Store Connect"
189 }
190
191 # ── Entry point ───────────────────────────────────────────────────────────────
192
193 STEP="${1:-all}"
194
195 case "$STEP" in
196 build) do_build ;;
197 sign) do_sign ;;
198 upload) do_upload ;;
199 all)
200 do_build
201 do_sign
202 do_upload
203 echo ""
204 echo "🎉 Pipeline complete: built, signed, and uploaded ${APP_NAME}."
205 ;;
206 *)
207 echo "Usage: $0 [build|sign|upload|all]" >&2
208 exit 1
209 ;;
210 esac