Files
nucleic/scripts/release-macos.sh
T
2026-07-12 17:30:55 -07:00

150 lines
7.9 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# One-command macOS release for a channel: build → sign (Developer ID) → notarize +
# staple the .app → build + sign the DMG → notarize + staple the DMG → (optionally)
# regenerate the Sparkle appcast.
#
# scripts/release-macos.sh {canary|beta|rc|stable} # dev isn't a distribution channel
#
# Prerequisites (one-time — see signing/README.md):
# • A "Developer ID Application" certificate in the keychain (auto-detected, or set
# NUCLEIC_SIGN_ID to its identity string / hash).
# • Notary credentials: a stored profile (NUCLEIC_NOTARY_PROFILE, default
# 'nucleic-notary') OR the NUCLEIC_ASC_* API-key env vars (see scripts/notarize.sh).
#
# Versioning: every release bumps the build number (CFBundleVersion) by 1 in ./VERSION, and
# bumps a semver component (CFBundleShortVersionString) — default minor (build-only on the
# fast-moving canary and dev channels), since we're pre-1.0. The new ./VERSION is committed on success (so versions/build
# numbers stay monotonic). Both the Makefile shortcut `make release-beta BUMP=major` and a direct
# `NUCLEIC_BUMP=build` work.
#
# Env:
# NUCLEIC_SIGN_ID signing identity (default: auto-detect Developer ID Application)
# NUCLEIC_PROVISIONING_PROFILE Developer ID profile authorizing the desktop App ID and shared
# Keychain group (default: channel-specific profile in signing/)
# NUCLEIC_BUMP semver bump: minor (default; build on canary/dev) | major | patch | build (build = build number only)
# NUCLEIC_VERSION_COMMIT commit the ./VERSION bump on success (default 1; 0 leaves it unstaged)
# NUCLEIC_VERSION pin the marketing version (overrides ./VERSION; forwarded to package-app.sh)
# NUCLEIC_NOTARIZE_APP notarize+staple the .app too (default 1; the DMG is always done)
# NUCLEIC_APPCAST regenerate the Sparkle appcast afterwards (default 1)
# NUCLEIC_R2_UPLOAD publish the DMG + appcast to R2 afterwards (default 1; needs wrangler)
# NUCLEIC_SYNC_LOWER after committing the bump, fast-forward VERSION on less-stable
# branches that now trail this release (default 1; see scripts/sync-version.sh)
set -euo pipefail
CHANNEL="${1:?usage: release-macos.sh (canary|beta|rc|stable)}"
case "$CHANNEL" in
canary) APP_NAME="Nucleic Canary" ;;
beta) APP_NAME="Nucleic Beta" ;;
rc) APP_NAME="Nucleic RC" ;;
stable) APP_NAME="Nucleic" ;;
dev) echo "release-macos: 'dev' is a local channel, not for distribution. Use make app-dev." >&2; exit 2 ;;
*) echo "usage: $0 {canary|beta|rc|stable}" >&2; exit 2 ;;
esac
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# Resolve a Developer ID Application identity; refuse to "release" an ad-hoc build.
SIGN_ID="${NUCLEIC_SIGN_ID:-}"
if [ -z "$SIGN_ID" ]; then
SIGN_ID="$(security find-identity -v -p codesigning 2>/dev/null \
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
fi
if [ -z "$SIGN_ID" ] || [ "$SIGN_ID" = "-" ]; then
cat >&2 <<EOF
release-macos: no "Developer ID Application" identity found.
Create one in Xcode → Settings → Accounts → (your paid team) → Manage Certificates
→ + → Developer ID Application, then re-run. Or set NUCLEIC_SIGN_ID explicitly.
Installed identities:
$(security find-identity -v -p codesigning 2>/dev/null | sed 's/^/ /')
EOF
exit 1
fi
export NUCLEIC_SIGN_ID="$SIGN_ID"
echo "▸ Releasing $CHANNEL with identity: $SIGN_ID"
# 0. Bump the version: build number always +1, plus a semver bump (NUCLEIC_BUMP, default minor —
# we're pre-1.0, so each release is a minor, except the canary/dev channels which default to
# build-only (no marketing-version change); major stays 0 until an explicit BUMP=major).
# bump-version.sh rewrites ./VERSION; package-app.sh reads it on the very next line. If the
# release fails before the artifact exists, an EXIT trap restores ./VERSION to its committed
# value so a retry reuses the same number instead of skipping one. On success the bump is
# committed (step 7); SUCCEEDED gates the trap so the bump survives.
SUCCEEDED=0
trap '[ "$SUCCEEDED" = 1 ] || git checkout -- VERSION 2>/dev/null || true' EXIT
# Default bump is channel-aware: the fast-moving canary and dev channels iterate constantly, so
# they default to build-only (build number +1, marketing version unchanged); the promotion
# channels (beta/rc/stable) default to a minor (we're pre-1.0). NUCLEIC_BUMP overrides either —
# e.g. `make release-canary BUMP=patch`.
DEFAULT_BUMP="minor"; case "$CHANNEL" in canary|dev) DEFAULT_BUMP="build" ;; esac
BUMP="${NUCLEIC_BUMP:-$DEFAULT_BUMP}"
read -r REL_VERSION REL_BUILD <<EOF
$(scripts/bump-version.sh "$BUMP")
EOF
echo "▸ Version: $REL_VERSION (build $REL_BUILD) [bump=$BUMP]"
# 1. Build + assemble + Developer-ID-sign the .app (picks up the bumped ./VERSION).
scripts/package-app.sh "$CHANNEL"
# 2. Notarize + staple the .app (so a copied-out app is trusted offline; default on).
APP="$ROOT/dist/$APP_NAME.app"
if [ "${NUCLEIC_NOTARIZE_APP:-1}" = "1" ]; then
scripts/notarize.sh "$APP"
fi
# 3. Build + sign the DMG (from the now-stapled app).
scripts/make-dmg.sh "$CHANNEL"
plist="$APP/Contents/Info.plist"
SHORT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist")"
BUILD_NUMBER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist")"
case "$CHANNEL" in canary) SLUG="Nucleic-Canary";; beta) SLUG="Nucleic-Beta";; rc) SLUG="Nucleic-RC";; stable) SLUG="Nucleic";; esac
DMG="$ROOT/dist/${SLUG}-${SHORT_VERSION}.${BUILD_NUMBER}.dmg"
# 4. Notarize + staple the DMG.
scripts/notarize.sh "$DMG"
# The distributable artifact now exists and is notarized — the version is "spent". Keep the bump
# (the EXIT trap will no longer revert ./VERSION); steps 57 are post-processing.
SUCCEEDED=1
# 5. Regenerate the Sparkle appcast for this channel (default on; skipped if absent).
if [ "${NUCLEIC_APPCAST:-1}" = "1" ] && [ -x scripts/generate-appcast.sh ]; then
scripts/generate-appcast.sh "$CHANNEL"
fi
# 6. Publish the DMG + appcast to R2 (updates.nucleic.blakeslee.xyz), the Sparkle feed host.
# Default on; needs wrangler + R2 auth. Set NUCLEIC_R2_UPLOAD=0 to upload manually instead.
if [ "${NUCLEIC_R2_UPLOAD:-1}" = "1" ] && [ -x scripts/upload-r2.sh ]; then
scripts/upload-r2.sh "$CHANNEL" \
|| echo " (R2 upload skipped/failed — upload dist/ to the bucket manually; see signing/README.md)"
fi
# 7. Record the version bump (the release succeeded) so versions/build numbers stay monotonic
# across releases. Off with NUCLEIC_VERSION_COMMIT=0 (then ./VERSION is left as a working
# change for you to commit). A release that failed before step 4 had its ./VERSION restored by
# the EXIT trap, so the next run reuses the same number rather than skipping one.
if [ "${NUCLEIC_VERSION_COMMIT:-1}" = "1" ] && ! git diff --quiet -- VERSION 2>/dev/null; then
git add VERSION
git commit -q -m "release($CHANNEL): v$REL_VERSION (build $REL_BUILD)" \
&& echo "▸ Committed version bump → v$REL_VERSION (build $REL_BUILD)"
fi
# 8. Propagate this version DOWN the channel chain: fast-forward any less-stable branch
# (dev/canary/…) whose VERSION now trails this release, so lower channels never lag a
# version a more-stable one already shipped. VERSION-only commits on local branches; push
# them with your usual pushes. Off with NUCLEIC_SYNC_LOWER=0; runs only when we committed
# the bump above (so the branch tip reflects the new version). Non-fatal — the release itself
# already succeeded.
if [ "${NUCLEIC_SYNC_LOWER:-1}" = "1" ] && [ "${NUCLEIC_VERSION_COMMIT:-1}" = "1" ] && [ -x scripts/sync-version.sh ]; then
scripts/sync-version.sh --apply \
|| echo " (version sync to lower branches skipped/failed — run 'make sync-versions APPLY=1' by hand)"
fi
echo
echo "✓ Release artifact ready:"
echo " $DMG"
echo " Hosted via R2 at ${NUCLEIC_FEED_BASE:-https://updates.nucleic.blakeslee.xyz} (GitHub release is a fallback) — see signing/README.md."