Files
soconnor 355b14faef Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing
fixes for distribution export. Add mobile invoice PDF preview, compact line
items, and more reliable shortcut deep-link handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:08:20 -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.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