Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
Executable
+387
@@ -0,0 +1,387 @@
|
||||
#!/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
|
||||
)
|
||||
|
||||
if [[ -f "$ROOT/$PROJECT/project.pbxproj" ]]; then
|
||||
node "$ROOT/scripts/configure-ios-signing.js" "$ROOT/$PROJECT/project.pbxproj"
|
||||
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 \
|
||||
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
|
||||
Reference in New Issue
Block a user