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>
This commit is contained in:
Executable
+384
@@ -0,0 +1,384 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user