#!/usr/bin/env bash # Archive beenvoice for iOS locally with Xcode and optionally upload to App Store Connect. # No EAS or paid Expo build services required — only Apple Developer + Xcode on macOS. # # Usage: # cp .ios-release.env.example .ios-release.env # once # bun run ios:release # archive + export IPA # bun run ios:release:upload # archive + upload to Connect # # Flags: # --upload Upload to App Store Connect after export (needs API key in .ios-release.env) # --archive-only Stop after .xcarchive (skip export/upload) # --export-only Export/upload from an existing archive (IOS_ARCHIVE_PATH) # --no-prebuild Skip `expo prebuild --platform ios` # --no-bump Skip build-number increment even if IOS_BUMP_BUILD=1 # --help set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" SCHEME="${IOS_SCHEME:-beenvoice}" WORKSPACE="${IOS_WORKSPACE:-ios/beenvoice.xcworkspace}" PROJECT="${IOS_PROJECT:-ios/beenvoice.xcodeproj}" CONFIGURATION="${IOS_CONFIGURATION:-Release}" DIST_DIR="${IOS_DIST_DIR:-dist/ios-release}" ARCHIVE_PATH="${IOS_ARCHIVE_PATH:-$DIST_DIR/beenvoice.xcarchive}" EXPORT_DIR="${IOS_EXPORT_DIR:-$DIST_DIR/export}" SCRIPT_DIR="$ROOT/scripts/ios-release" DO_UPLOAD=0 ARCHIVE_ONLY=0 EXPORT_ONLY=0 SKIP_PREBUILD="${IOS_SKIP_PREBUILD:-0}" NO_BUMP=0 usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' } for arg in "$@"; do case "$arg" in --upload) DO_UPLOAD=1 ;; --archive-only) ARCHIVE_ONLY=1 ;; --export-only) EXPORT_ONLY=1 ;; --no-prebuild) SKIP_PREBUILD=1 ;; --no-bump) NO_BUMP=1 ;; --help|-h) usage exit 0 ;; *) echo "Unknown option: $arg" >&2 usage >&2 exit 1 ;; esac done if [[ "$(uname -s)" != "Darwin" ]]; then echo "Error: iOS release must run on macOS with Xcode installed." >&2 exit 1 fi if ! command -v xcodebuild >/dev/null 2>&1; then echo "Error: xcodebuild not found. Install Xcode from the App Store." >&2 exit 1 fi if [[ -f "$ROOT/.ios-release.env" ]]; then # shellcheck disable=SC1091 set -a source "$ROOT/.ios-release.env" set +a fi require_var() { if [[ -z "${!1:-}" ]]; then echo "Error: $1 is required. Set it in .ios-release.env or the environment." >&2 exit 1 fi } load_api_auth_args() { API_AUTH_ARGS=() if [[ -n "${APP_STORE_CONNECT_API_KEY_ID:-}" ]]; then require_var APP_STORE_CONNECT_API_ISSUER_ID require_var APP_STORE_CONNECT_API_KEY_PATH if [[ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]]; then echo "Error: API key not found at $APP_STORE_CONNECT_API_KEY_PATH" >&2 exit 1 fi API_AUTH_ARGS=( -authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" -authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" ) fi } write_export_plist() { local template="$1" local dest="$2" require_var APPLE_TEAM_ID sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" "$template" >"$dest" } check_distribution_signing() { if security find-identity -v -p codesigning 2>/dev/null | grep -q 'Apple Distribution'; then return 0 fi echo "" >&2 echo "Error: No local \"Apple Distribution\" signing certificate found." >&2 echo "App Store export needs a distribution cert (development-only certs are not enough)." >&2 echo "" >&2 echo "Fix in Xcode:" >&2 echo " 1. Xcode → Settings → Accounts" >&2 echo " 2. Select your Apple ID → team $APPLE_TEAM_ID → Manage Certificates…" >&2 echo " 3. Click + → Apple Distribution" >&2 echo " 4. Re-run: bun run ios:release:upload" >&2 echo "" >&2 echo "If + is disabled, your Apple Developer role may not allow creating certs." >&2 echo "Ask the Account Holder to add you as Admin, or create the distribution cert for you." >&2 echo "" >&2 echo "Installed signing identities:" >&2 security find-identity -v -p codesigning 2>/dev/null | sed 's/^/ /' >&2 || true exit 1 } print_profile_mismatch_help() { echo "" >&2 echo "Export failed: App Store provisioning profiles don't include your distribution certificate." >&2 echo "This usually happens right after creating a new Apple Distribution cert." >&2 echo "" >&2 echo "Fix (pick one):" >&2 echo "" >&2 echo "A) developer.apple.com → Profiles" >&2 echo " • Open each App Store profile for:" >&2 echo " - com.beenvoice.app" >&2 echo " - com.beenvoice.app.ExpoWidgetsTarget" >&2 echo " • Edit → select your current Apple Distribution certificate → Save" echo " • Save (regenerates the profile)" >&2 echo "" >&2 echo "B) Xcode → Settings → Accounts → team $APPLE_TEAM_ID" >&2 echo " • Manage Certificates… → revoke duplicate/old Apple Distribution certs" >&2 echo " • Download Manual Profiles" >&2 echo "" >&2 echo "Then re-archive (export-only is not enough after cert/profile changes):" >&2 echo " bun run ios:release:upload" >&2 echo "" >&2 if [[ ${#API_AUTH_ARGS[@]} -eq 0 ]]; then echo "Tip: set App Store Connect API credentials in .ios-release.env so export can" >&2 echo "refresh profiles via cloud signing (-allowProvisioningUpdates)." >&2 echo "" >&2 fi } resolve_xcode_workspace() { if [[ -d "$ROOT/$WORKSPACE" ]]; then XCODE_ARGS=(-workspace "$ROOT/$WORKSPACE") elif [[ -d "$ROOT/$PROJECT" ]]; then echo "Note: $WORKSPACE missing — using $PROJECT (run pod install if linking fails)." XCODE_ARGS=(-project "$ROOT/$PROJECT") else echo "Error: No Xcode workspace or project under ios/. Run: bunx expo prebuild --platform ios" >&2 exit 1 fi } prepare_native_project() { if [[ "$SKIP_PREBUILD" != "1" && "$EXPORT_ONLY" != "1" ]]; then echo "==> Syncing native iOS project (expo prebuild)…" bunx expo prebuild --platform ios fi echo "==> Installing CocoaPods…" ( cd "$ROOT/ios" if command -v pod >/dev/null 2>&1; then pod install else bunx pod-install fi ) resolve_xcode_workspace } bump_build_number() { if [[ "$NO_BUMP" == "1" ]]; then return fi if [[ "${IOS_BUMP_BUILD:-0}" != "1" ]]; then return fi echo "==> Incrementing iOS build number in app.json…" local next_build next_build="$( node -e " const fs = require('fs'); const path = '$ROOT/app.json'; const app = JSON.parse(fs.readFileSync(path, 'utf8')); const ios = app.expo.ios ?? (app.expo.ios = {}); const current = Number.parseInt(ios.buildNumber ?? '0', 10); const next = Number.isFinite(current) ? current + 1 : 1; ios.buildNumber = String(next); fs.writeFileSync(path, JSON.stringify(app, null, 2) + '\n'); process.stdout.write(String(next)); " )" echo "Build number: $next_build (expo.ios.buildNumber)" } read_ipa_build_number() { local ipa="$1" local tmp plist tmp="$(mktemp -d)" plist="$tmp/Info.plist" if ! unzip -q -j "$ipa" "Payload/*.app/Info.plist" -d "$tmp" 2>/dev/null; then rm -rf "$tmp" return 1 fi plutil -extract CFBundleVersion raw "$plist" 2>/dev/null rm -rf "$tmp" } archive_app() { mkdir -p "$(dirname "$ARCHIVE_PATH")" export EXPO_PUBLIC_API_URL="${EXPO_PUBLIC_API_URL:-https://beenvoice.soconnor.dev}" load_api_auth_args echo "==> Archiving (EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL)…" # Release archive for generic iOS devices (App Store). xcodebuild \ "${XCODE_ARGS[@]}" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ -archivePath "$ARCHIVE_PATH" \ -destination "generic/platform=iOS" \ -allowProvisioningUpdates \ CODE_SIGN_STYLE=Automatic \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ "${API_AUTH_ARGS[@]}" \ archive local signing_identity signing_identity="$( plutil -extract ApplicationProperties.SigningIdentity raw "$ARCHIVE_PATH/Info.plist" 2>/dev/null || true )" if [[ -n "$signing_identity" && "$signing_identity" != *"Distribution"* ]]; then echo "Note: archive signed with \"$signing_identity\" (export re-signs for App Store)." fi echo "Archive: $ARCHIVE_PATH (signed: ${signing_identity:-unknown})" } export_ipa() { require_var APPLE_TEAM_ID check_distribution_signing load_api_auth_args mkdir -p "$EXPORT_DIR" local export_plist="$DIST_DIR/ExportOptions.plist" write_export_plist "$SCRIPT_DIR/ExportOptions.appstore.plist" "$export_plist" echo "==> Exporting App Store IPA…" set +e local export_log export_log="$(mktemp)" xcodebuild \ -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportPath "$EXPORT_DIR" \ -exportOptionsPlist "$export_plist" \ -allowProvisioningUpdates \ "${API_AUTH_ARGS[@]}" 2>&1 | tee "$export_log" local export_status=${PIPESTATUS[0]} set -e if [[ "$export_status" -ne 0 ]]; then if grep -q "doesn't include signing certificate" "$export_log" \ || grep -q "Cloud signing permission error" "$export_log"; then print_profile_mismatch_help fi rm -f "$export_log" exit "$export_status" fi rm -f "$export_log" local ipa ipa="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' | head -1)" if [[ -z "$ipa" ]]; then echo "Error: export finished but no .ipa was found in $EXPORT_DIR" >&2 exit 1 fi echo "IPA: $ipa" UPLOAD_IPA="$ipa" } upload_to_connect() { load_api_auth_args if [[ ${#API_AUTH_ARGS[@]} -eq 0 ]]; then echo "Error: Upload requires App Store Connect API credentials in .ios-release.env" >&2 echo " APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, APP_STORE_CONNECT_API_KEY_PATH" >&2 exit 1 fi if [[ -z "${UPLOAD_IPA:-}" || ! -f "$UPLOAD_IPA" ]]; then echo "Error: No IPA to upload. Run export first." >&2 exit 1 fi local ipa_build ipa_build="$(read_ipa_build_number "$UPLOAD_IPA" || true)" if [[ -n "$ipa_build" ]]; then echo "Uploading build ${ipa_build}..." fi echo "==> Uploading IPA to App Store Connect (altool)…" set +e local upload_log upload_log="$(mktemp)" xcrun altool --upload-app \ --type ios \ --file "$UPLOAD_IPA" \ --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \ --apiKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" 2>&1 | tee "$upload_log" local upload_status=${PIPESTATUS[0]} set -e if [[ "$upload_status" -ne 0 ]]; then if grep -q "bundle version must be higher" "$upload_log"; then echo "" >&2 echo "Upload failed: build ${ipa_build:-?} was already uploaded." >&2 echo "Bump expo.ios.buildNumber in app.json (now 6+), then re-run a full release:" >&2 echo " bun run ios:release:upload" >&2 echo "" >&2 echo "Do not use --export-only — that re-exports an old archive with the same build number." >&2 fi rm -f "$upload_log" exit "$upload_status" fi rm -f "$upload_log" echo "Upload complete. Processing continues in App Store Connect → TestFlight." } main() { require_var APPLE_TEAM_ID if [[ "$EXPORT_ONLY" != "1" ]]; then bump_build_number prepare_native_project archive_app else resolve_xcode_workspace if [[ ! -d "$ARCHIVE_PATH" ]]; then echo "Error: archive not found at $ARCHIVE_PATH" >&2 exit 1 fi fi if [[ "$ARCHIVE_ONLY" == "1" ]]; then echo "Done (archive only)." exit 0 fi if [[ "$DO_UPLOAD" == "1" ]]; then export_ipa upload_to_connect else export_ipa echo "" echo "Next: bun run ios:release:upload --export-only" echo " or open Transporter and drop the IPA from $EXPORT_DIR" fi echo "Done." } main