#!/usr/bin/env bash # # Archive + upload a NucleicRemote iPhone build to App Store Connect (→ TestFlight). # # scripts/ios-release.sh {beta|canary} [--archive-only] # # iOS ships two coexisting TestFlight apps — the same channel model as the Mac app, minus the rc # channel. Each channel is a distinct app record (distinct bundle id) so all can be installed # side-by-side, exactly like the Mac's separate "Nucleic Beta", "Nucleic Canary", and "Nucleic": # # channel bundle id display name icon default bump # ------- ------------------------------------- -------------- ---------------- ------------ # beta xyz.blakeslee.nucleic-remote.beta Nucleic Beta app-logo-beta minor # canary xyz.blakeslee.nucleic-remote.canary Nucleic Canary app-logo-canary build # # The bare base id (xyz.blakeslee.nucleic-remote / "Nucleic" / app-logo-file — the Xcode project # defaults) is reserved for the stable App Store release, kept separate from the beta app so a beta # TestFlight build never shares stable's record. # # The channel is stamped into Info.plist (NucleicChannel → BuildBanner.swift) and drives the # per-channel bundle id / display name / app icon via build settings the archive step overrides # (NUCLEIC_BUNDLE_SUFFIX / NUCLEIC_NAME_SUFFIX / NUCLEIC_APPICON, defaulted in the Xcode project to # the bare base/stable identity so a plain `xcodebuild`/Xcode build still produces a runnable app). # The widget extension's bundle id tracks the app's via the same suffix (…$(NUCLEIC_BUNDLE_SUFFIX).widgets). # # Prerequisites (one-time — see signing/README.md): # • An "Apple Distribution" certificate for the paid team (L7UDTQ6F5W) in the keychain. # • BOTH App IDs registered + an app record created in App Store Connect, one per channel: # xyz.blakeslee.nucleic-remote.beta (+ .beta.widgets) and xyz.blakeslee.nucleic-remote.canary (+ .canary.widgets) # (The bare xyz.blakeslee.nucleic-remote (+ .widgets) is the separate stable App Store record.) # • An App Store Connect API key (the same one notarization uses), passed by env: # NUCLEIC_ASC_KEY_PATH=/path/AuthKey_XXXX.p8 # NUCLEIC_ASC_KEY_ID= # NUCLEIC_ASC_ISSUER_ID= # With the key + -allowProvisioningUpdates, Xcode creates/downloads the distribution profile. # # Versioning: iOS keeps its own version state in ios/VERSION (seeded at 0.1.0), shared across both # channels (as the Mac's ./VERSION is shared across its channels) and bumped by the same # scripts/bump-version.sh. The default bump is channel-aware, mirroring the Mac: the fast-moving # canary channel defaults to build-only (build number +1, marketing version unchanged), while beta # defaults to a minor (we're pre-1.0). NUCLEIC_BUMP overrides either — e.g. BUMP=major|patch. A # failed run restores ios/VERSION so the build number isn't spent; a successful upload commits it # so numbers stay monotonic (per app, since the shared counter only ever increases). # NUCLEIC_BUMP semver bump: build | patch | minor | major (default: minor; canary: build) # NUCLEIC_VERSION pin the marketing version (skips the ios/VERSION bump) # NUCLEIC_IOS_BUILD pin the build number (skips the ios/VERSION bump) # NUCLEIC_VERSION_COMMIT commit the ios/VERSION bump on success (default 1; 0 leaves it unstaged) set -euo pipefail # Parse args: a channel keyword (beta|canary) and/or the --archive-only flag, in any order. CHANNEL="" ARCHIVE_ONLY=0 for arg in "$@"; do case "$arg" in --archive-only) ARCHIVE_ONLY=1 ;; beta|canary) CHANNEL="$arg" ;; *) echo "usage: $0 {beta|canary} [--archive-only]" >&2; exit 2 ;; esac done CHANNEL="${CHANNEL:-beta}" # default to the public beta # Per-channel identity. The suffixes are appended to the base bundle id / app name; the icon is the # Icon Composer package selected by ASSETCATALOG_COMPILER_APPICON_NAME. Both distributed channels are # distinct apps layered on the base identity via their own suffix + icon (repo-root .icon packages # wired into the Xcode project) — mirroring the Mac's separate "Nucleic Beta" and "Nucleic Canary". # The bare base identity (empty suffixes / app-logo-file, the Xcode project defaults) is reserved for # the stable App Store release, so a beta build no longer collides with stable. case "$CHANNEL" in beta) BUNDLE_SUFFIX=".beta"; NAME_SUFFIX=" Beta"; APPICON="app-logo-beta"; DEFAULT_BUMP="minor" ;; canary) BUNDLE_SUFFIX=".canary"; NAME_SUFFIX=" Canary"; APPICON="app-logo-canary"; DEFAULT_BUMP="build" ;; *) echo "usage: $0 {beta|canary} [--archive-only]" >&2; exit 2 ;; esac ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" PROJ="ios/NucleicRemote/NucleicRemote.xcodeproj" SCHEME="NucleicRemote" ARCHIVE="$ROOT/dist/ios/NucleicRemote-$CHANNEL.xcarchive" EXPORT_DIR="$ROOT/dist/ios/export-$CHANNEL" IOS_VERSION_FILE="$ROOT/ios/VERSION" # Resolve the version. Default path: bump ios/VERSION (same mechanism as the Mac release) and, # unless it succeeds, restore it on exit so a failed upload doesn't spend the build number. If # NUCLEIC_VERSION or NUCLEIC_IOS_BUILD is set, treat it as an explicit pin and leave the file alone. # The default bump is channel-aware (canary: build-only; beta: minor), overridable via NUCLEIC_BUMP. BUMP="${NUCLEIC_BUMP:-$DEFAULT_BUMP}" BUMPED=0 SUCCEEDED=0 RSYNC_SHIM="" # Single EXIT handler: restore an unspent version bump on failure (so a failed upload doesn't # skip a build number), and remove the rsync shim dir (set up before the export, below). cleanup() { [ "$SUCCEEDED" = 1 ] || [ "$BUMPED" = 0 ] || git checkout -- ios/VERSION 2>/dev/null || true [ -n "$RSYNC_SHIM" ] && rm -rf "$RSYNC_SHIM" 2>/dev/null || true } trap cleanup EXIT if [ -n "${NUCLEIC_VERSION:-}" ] || [ -n "${NUCLEIC_IOS_BUILD:-}" ]; then NUCLEIC_MARKETING_VERSION=0.1.0; NUCLEIC_BUILD_NUMBER=0 # shellcheck source=/dev/null [ -f "$IOS_VERSION_FILE" ] && . "$IOS_VERSION_FILE" MARKETING_VERSION="${NUCLEIC_VERSION:-$NUCLEIC_MARKETING_VERSION}" BUILD_NUMBER="${NUCLEIC_IOS_BUILD:-$NUCLEIC_BUILD_NUMBER}" echo "▸ Version (pinned): $MARKETING_VERSION (build $BUILD_NUMBER)" else read -r MARKETING_VERSION BUILD_NUMBER </dev/null)" != /usr/bin/rsync ]; then RSYNC_SHIM="$(mktemp -d)" ln -sf /usr/bin/rsync "$RSYNC_SHIM/rsync" echo "▸ Pinning rsync → /usr/bin/rsync (shadowing $(command -v rsync)) for the Xcode export" PATH="$RSYNC_SHIM:$PATH"; export PATH fi # App Store Connect API-key flags for xcodebuild (provisioning updates + upload). AUTH=() if [ -n "${NUCLEIC_ASC_KEY_PATH:-}" ] && [ -n "${NUCLEIC_ASC_KEY_ID:-}" ] && [ -n "${NUCLEIC_ASC_ISSUER_ID:-}" ]; then AUTH=(-authenticationKeyPath "$NUCLEIC_ASC_KEY_PATH" -authenticationKeyID "$NUCLEIC_ASC_KEY_ID" -authenticationKeyIssuerID "$NUCLEIC_ASC_ISSUER_ID") elif [ "$ARCHIVE_ONLY" -eq 0 ]; then cat >&2 < export NUCLEIC_ASC_ISSUER_ID= Or run with --archive-only to just build the archive (then upload via Xcode Organizer). EOF exit 1 fi echo "▸ Archiving $SCHEME ($CHANNEL) (version $MARKETING_VERSION, build $BUILD_NUMBER)" rm -rf "$ARCHIVE" xcodebuild \ -project "$PROJ" \ -scheme "$SCHEME" \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$ARCHIVE" \ -allowProvisioningUpdates \ "${AUTH[@]}" \ MARKETING_VERSION="$MARKETING_VERSION" \ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ NUCLEIC_CHANNEL="$CHANNEL" \ NUCLEIC_BUNDLE_SUFFIX="$BUNDLE_SUFFIX" \ NUCLEIC_NAME_SUFFIX="$NAME_SUFFIX" \ NUCLEIC_APPICON="$APPICON" \ clean archive if [ "$ARCHIVE_ONLY" -eq 1 ]; then # A real archive with this version now exists; keep the bump (don't let the trap revert it) so a # later automated upload won't reuse the same build number, but leave it uncommitted since we # didn't upload. SUCCEEDED=1 echo "✓ Archive built: $ARCHIVE" echo " Upload via Xcode → Window → Organizer → Distribute App → App Store Connect." [ "$BUMPED" -eq 1 ] && echo " (ios/VERSION bumped to $MARKETING_VERSION build $BUILD_NUMBER, left uncommitted.)" exit 0 fi echo "▸ Exporting + uploading to App Store Connect (TestFlight)" rm -rf "$EXPORT_DIR" xcodebuild -exportArchive \ -archivePath "$ARCHIVE" \ -exportOptionsPlist ios/ExportOptions.plist \ -exportPath "$EXPORT_DIR" \ -allowProvisioningUpdates \ "${AUTH[@]}" # The build is uploaded — the version is "spent". Keep the bump (the EXIT trap no longer reverts # ios/VERSION) and commit it so build numbers stay monotonic across uploads. SUCCEEDED=1 if [ "$BUMPED" -eq 1 ] && [ "${NUCLEIC_VERSION_COMMIT:-1}" = "1" ] && ! git diff --quiet -- ios/VERSION 2>/dev/null; then git add ios/VERSION git commit -q -m "release(ios/$CHANNEL): v$MARKETING_VERSION (build $BUILD_NUMBER)" \ && echo "▸ Committed iOS version bump → v$MARKETING_VERSION (build $BUILD_NUMBER)" fi echo "✓ Uploaded $CHANNEL build $BUILD_NUMBER ($MARKETING_VERSION) to App Store Connect." echo " It will appear in TestFlight after processing. Assign it to a tester group there," echo " or submit it for App Store review to ship a release."