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>
385 lines
11 KiB
Bash
Executable File
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
|