| 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 |