Files
beenvoice-app/scripts/ios-release.sh
T
soconnorandCursor 6762a9bff3 Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.
Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 03:40:48 -04:00

385 lines
11 KiB
Bash
Executable File

#!/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.app}"
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