Files
nucleic/scripts/ios-release.sh
T

198 lines
10 KiB
Bash
Executable File

#!/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=<KeyID>
# NUCLEIC_ASC_ISSUER_ID=<IssuerID>
# 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 <<EOF
$(NUCLEIC_VERSION_FILE="$IOS_VERSION_FILE" scripts/bump-version.sh "$BUMP")
EOF
BUMPED=1
echo "▸ Version: $MARKETING_VERSION (build $BUILD_NUMBER) [channel=$CHANNEL bump=$BUMP]"
fi
# rsync pin (Xcode exportArchive workaround). Xcode's IPA-packaging step runs the system openrsync
# (/usr/bin/rsync) with Apple's --extended-attributes flag, but openrsync spawns its local copy
# "server" by resolving `rsync` on PATH. If a Homebrew/samba rsync (which doesn't support
# --extended-attributes) is ahead on PATH, the copy dies with
# rsync: on remote machine: --extended-attributes: unknown option … Copy failed
# and the export fails. Pin `rsync` to the system binary for our xcodebuild subprocesses so both
# ends of the copy are openrsync. Only kicks in when a different rsync is currently shadowing it.
if [ -x /usr/bin/rsync ] && [ "$(command -v rsync 2>/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 <<EOF
ios-release: upload requested but no App Store Connect API key in the environment.
export NUCLEIC_ASC_KEY_PATH=/path/AuthKey_XXXX.p8
export NUCLEIC_ASC_KEY_ID=<KeyID>
export NUCLEIC_ASC_ISSUER_ID=<IssuerID>
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."