openclaw/scripts/package-mac-dist.sh

468 lines
16 KiB
Bash
Executable file

#!/bin/bash
set -euo pipefail
# Build the mac app bundle, then create a zip (Sparkle) + styled DMG (humans).
#
# Output:
# - dist/OpenClaw.app
# - dist/OpenClaw-<version>.zip
# - dist/OpenClaw-<version>.dmg
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/plistbuddy.sh"
source "$ROOT_DIR/scripts/lib/mac-signing-identity.sh"
source "$ROOT_DIR/scripts/lib/swift-toolchain.sh"
RECOVERY_DIR="$ROOT_DIR/dist/macos-notarization-recovery"
RECOVERY_HELPER="$ROOT_DIR/scripts/lib/mac-notarization-recovery.py"
RESUME_NOTARIZATION=0
CHECKPOINT_ONLY=0
RECOVERY_READY=0
RESTORED_APP_DIR=""
finish_recovery_checkpoint() {
local result=$?
if [[ "$RECOVERY_READY" == "1" ]]; then
python3 "$RECOVERY_HELPER" seal "$RECOVERY_DIR" || result=1
fi
if [[ -n "$RESTORED_APP_DIR" ]]; then
rm -rf "$RESTORED_APP_DIR"
fi
exit "$result"
}
trap finish_recovery_checkpoint EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
case "${1:-}" in
--resume-notarization) RESUME_NOTARIZATION=1; shift ;;
--checkpoint-only) CHECKPOINT_ONLY=1; shift ;;
--help) echo "Usage: scripts/package-mac-dist.sh [--checkpoint-only | --resume-notarization]"; exit 0 ;;
esac
if [[ "$#" -ne 0 ]]; then
echo "Error: unexpected packaging argument: $1" >&2
exit 1
fi
if [[ "$RESUME_NOTARIZATION" == "0" && -e "$RECOVERY_DIR" ]]; then
if ! python3 "$RECOVERY_HELPER" retire-completed "$RECOVERY_DIR"; then
echo "Error: unfinished or invalid notarization checkpoint; use --resume-notarization or inspect it before a new build." >&2
exit 1
fi
fi
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
PRODUCT="OpenClaw"
BUILD_CONFIG="${BUILD_CONFIG:-release}"
APP_VERSION_INPUT="${APP_VERSION:-}"
if [[ "$CHECKPOINT_ONLY" == "1" && "$BUILD_CONFIG" != "release" ]]; then
echo "Error: --checkpoint-only requires BUILD_CONFIG=release, including smoke builds." >&2
exit 1
fi
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
export BUILD_CONFIG
export OPENCLAW_CONTROL_UI_RELEASE_BUILD=1
DSYM_ARCHS_VALUE="$BUILD_ARCHS"
if [[ "$DSYM_ARCHS_VALUE" == "all" ]]; then
DSYM_ARCHS_VALUE="arm64 x86_64"
fi
IFS=' ' read -r -a DSYM_ARCHS <<< "$DSYM_ARCHS_VALUE"
# Use release bundle ID (not .debug) so Sparkle auto-update works.
# The .debug suffix in package-mac-app.sh blanks SUFeedURL intentionally for dev builds.
export BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac}"
DIST_PNPM_CMD=()
SPARKLE_BUILD_DEPS_RETRIED=0
resolve_dist_pnpm_cmd() {
if command -v corepack >/dev/null 2>&1 && (cd "$ROOT_DIR" && corepack pnpm --version >/dev/null 2>&1); then
DIST_PNPM_CMD=(corepack pnpm)
return 0
fi
if command -v pnpm >/dev/null 2>&1; then
DIST_PNPM_CMD=(pnpm)
return 0
fi
echo "ERROR: pnpm is not on PATH and corepack pnpm is unavailable. Install pnpm or run with Node/Corepack on PATH." >&2
exit 1
}
run_dist_pnpm() {
if [[ "${#DIST_PNPM_CMD[@]}" -eq 0 ]]; then
resolve_dist_pnpm_cmd
fi
(cd "$ROOT_DIR" && "${DIST_PNPM_CMD[@]}" "$@")
}
ensure_sparkle_build_deps() {
echo "📦 Ensuring deps for Sparkle build metadata" >&2
run_dist_pnpm install --frozen-lockfile --config.node-linker=hoisted >&2
}
run_sparkle_build_node() {
(cd "$ROOT_DIR" && node --import tsx "$ROOT_DIR/scripts/sparkle-build.ts" canonical-build "$1")
}
canonical_sparkle_build() {
local version="$1"
local output
local stderr_file
stderr_file="$(mktemp "${TMPDIR:-/tmp}/openclaw-sparkle-build.XXXXXX")" || {
echo "ERROR: failed to create temporary stderr capture for Sparkle build metadata." >&2
return 1
}
if output="$(run_sparkle_build_node "$version" 2>"$stderr_file")"; then
if [[ -s "$stderr_file" ]]; then
cat "$stderr_file" >&2
fi
rm -f "$stderr_file"
printf '%s\n' "$output"
return 0
fi
if [[ "$SPARKLE_BUILD_DEPS_RETRIED" == "1" ]]; then
cat "$stderr_file" >&2
rm -f "$stderr_file"
return 1
fi
rm -f "$stderr_file"
SPARKLE_BUILD_DEPS_RETRIED=1
ensure_sparkle_build_deps || return 1
run_sparkle_build_node "$version"
}
require_canonical_sparkle_build() {
local version="$1"
local build
if ! build="$(canonical_sparkle_build "$version")" || [[ ! "$build" =~ ^[0-9]+$ ]]; then
echo "Error: failed to derive canonical Sparkle build for '$version'." >&2
exit 1
fi
printf '%s\n' "$build"
}
correction_build_from_exact_tag() {
local version="$1"
local canonical="$2"
local tag correction highest
highest=""
while IFS= read -r tag; do
if [[ "$tag" =~ ^v${version//./\\.}-([1-9][0-9]*)$ ]]; then
correction="${BASH_REMATCH[1]}"
if [[ -z "$highest" || "$correction" -gt "$highest" ]]; then
highest="$correction"
fi
fi
done < <(git -C "$ROOT_DIR" tag --points-at HEAD 2>/dev/null || true)
if [[ -n "$highest" ]]; then
printf '%s\n' "$((canonical + highest))"
fi
}
# Local fallback releases must not silently fall back to a git-rev-count build number.
# For correction tags, pass a higher explicit APP_BUILD than the canonical floor.
if [[ "$RESUME_NOTARIZATION" == "0" ]]; then
require_swift_toolchain
fi
if [[ -z "$APP_VERSION_INPUT" ]]; then
APP_VERSION_INPUT="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
fi
if [[ "$RESUME_NOTARIZATION" == "0" && -z "${APP_BUILD:-}" && "$BUILD_CONFIG" == "release" ]]; then
CANONICAL_APP_BUILD="$(require_canonical_sparkle_build "$APP_VERSION_INPUT")"
APP_BUILD="$(correction_build_from_exact_tag "$APP_VERSION_INPUT" "$CANONICAL_APP_BUILD")"
export APP_BUILD="${APP_BUILD:-$CANONICAL_APP_BUILD}"
fi
APP="$ROOT_DIR/dist/OpenClaw.app"
if [[ "$RESUME_NOTARIZATION" == "1" ]]; then
python3 "$RECOVERY_HELPER" verify "$RECOVERY_DIR" "$(git -C "$ROOT_DIR" rev-parse HEAD)" "$APP_VERSION_INPUT" >/dev/null
APP_BUILD="$(jq -r '.build' "$RECOVERY_DIR/manifest.json")"
SKIP_DMG="$(jq -r 'if .skipDmg then "1" else "0" end' "$RECOVERY_DIR/manifest.json")"
SKIP_DSYM="$(jq -r 'if .skipDsym then "1" else "0" end' "$RECOVERY_DIR/manifest.json")"
RESTORED_APP_DIR="$(mktemp -d "$ROOT_DIR/dist/.notary-resume.XXXXXX")"
ditto -x -k "$RECOVERY_DIR/app.zip" "$RESTORED_APP_DIR"
APP="$RESTORED_APP_DIR/OpenClaw.app"
/usr/bin/codesign --verify --deep --strict "$APP"
if [[ "${SKIP_NOTARIZE:-0}" != "1" && -n "${EXPECTED_DEVELOPER_TEAM_ID:-}" ]]; then
/usr/bin/codesign --verify --strict -R="anchor apple generic and certificate leaf[subject.OU] = \"${EXPECTED_DEVELOPER_TEAM_ID}\"" "$APP"
fi
else
# A smoke request must be deterministic even on a host with release certificates.
# Preserve explicit identities so the signed-app notarization guard still applies.
if [[ "${SKIP_NOTARIZE:-0}" == "1" && "${ALLOW_ADHOC_SIGNING:-0}" == "1" && -z "${SIGN_IDENTITY:-}" ]]; then
SIGN_IDENTITY="-"
fi
SIGN_IDENTITY="$(resolve_mac_signing_identity)"
export SIGN_IDENTITY
"$ROOT_DIR/scripts/package-mac-app.sh"
fi
if [[ ! -d "$APP" ]]; then
echo "Error: missing app bundle at $APP" >&2
exit 1
fi
audit_app_async_frames() {
local executable="$1/Contents/MacOS/$PRODUCT" app_archs
app_archs="$(/usr/bin/lipo -archs "$executable")"
case " $app_archs " in
*" arm64 "*)
python3 "$ROOT_DIR/apps/macos/scripts/audit-async-sleep-frames.py" "$executable"
;;
*)
# The audit understands arm64 frames only. An explicitly x86_64-only build variant
# ships no arm64 slice, so there is nothing to audit; every other variant must carry one.
if [[ "$BUILD_ARCHS" == "x86_64" ]]; then
echo "Async frame audit not applicable: x86_64-only build has no arm64 slice ($executable)" >&2
return 0
fi
echo "Error: release executable has no arm64 slice; audit cannot run: $executable" >&2
return 1
;;
esac
}
audit_retained_dmg_async_frames() (
set -euo pipefail
mount_dir="$(mktemp -d "$ROOT_DIR/dist/.notary-dmg.XXXXXX")"
mounted=0
cleanup_audit_mount() {
local result=$?
if [[ "$mounted" == "1" ]]; then
hdiutil detach "$mount_dir" >/dev/null || result=1
fi
rmdir "$mount_dir" || result=1
exit "$result"
}
trap cleanup_audit_mount EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$1" >/dev/null
mounted=1
audit_app_async_frames "$mount_dir/OpenClaw.app"
)
if [[ "$BUILD_CONFIG" == "release" ]]; then
# Recovery must recheck the retained bytes, including checkpoints made before this gate existed.
audit_app_async_frames "$APP"
fi
VERSION="$(plist_print_required "$APP/Contents/Info.plist" CFBundleShortVersionString)"
BUNDLE_VERSION="$(plist_print_required "$APP/Contents/Info.plist" CFBundleVersion)"
ACTUAL_BUNDLE_ID="$(plist_print_required "$APP/Contents/Info.plist" CFBundleIdentifier)"
ACTUAL_FEED_URL="$(plist_print_required "$APP/Contents/Info.plist" SUFeedURL)"
ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.zip"
DMG="$ROOT_DIR/dist/OpenClaw-$VERSION.dmg"
NOTARY_ZIP="$RECOVERY_DIR/app.zip"
DSYM_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.dSYM.zip"
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
NOTARIZE=1
SKIP_DSYM="${SKIP_DSYM:-0}"
SKIP_DMG="${SKIP_DMG:-0}"
cleanup_tmp_dsym() {
rm -rf "$TMP_DSYM"
}
copy_dsym_to_tmp() {
if ! cp -R "$1" "$TMP_DSYM"; then
cleanup_tmp_dsym
exit 1
fi
}
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
if [[ "${ALLOW_ADHOC_SIGNING:-0}" != "1" && "${SIGN_IDENTITY:-}" != "-" ]]; then
echo "Error: SKIP_NOTARIZE=1 is only allowed for explicit ad-hoc smoke builds." >&2
exit 1
fi
# Drain codesign output so a match cannot cause SIGPIPE under pipefail.
if ! /usr/bin/codesign --display --verbose=4 "$APP" 2>&1 | grep -Fx 'Signature=adhoc' >/dev/null; then
echo "Error: skipping notarization requires an ad-hoc signed smoke app." >&2
exit 1
fi
NOTARIZE=0
fi
if [[ "$RESUME_NOTARIZATION" == "1" ]]; then
if [[ "$BUILD_CONFIG" != "release" || "$VERSION" != "$APP_VERSION_INPUT" || "$BUNDLE_VERSION" != "$APP_BUILD" ]]; then
echo "Error: resumed app does not match the signed release checkpoint." >&2
exit 1
fi
RECOVERY_READY=1
fi
if [[ "$BUILD_CONFIG" == "release" ]]; then
if [[ "$ACTUAL_BUNDLE_ID" != "$BUNDLE_ID" ]]; then
echo "Error: release packaging produced bundle id '$ACTUAL_BUNDLE_ID', expected '$BUNDLE_ID'." >&2
exit 1
fi
if [[ -z "$ACTUAL_FEED_URL" ]]; then
echo "Error: release packaging produced an empty SUFeedURL." >&2
exit 1
fi
if [[ "$RESUME_NOTARIZATION" == "1" ]]; then
# The exact source-bound bundle already passed this gate before retention.
CANONICAL_APP_BUILD="$APP_BUILD"
else
CANONICAL_APP_BUILD="$(require_canonical_sparkle_build "$VERSION")"
fi
if [[ ! "$BUNDLE_VERSION" =~ ^[0-9]+$ ]]; then
echo "Error: release packaging produced non-numeric CFBundleVersion '$BUNDLE_VERSION'." >&2
exit 1
fi
if (( BUNDLE_VERSION < CANONICAL_APP_BUILD )); then
echo "Error: CFBundleVersion '$BUNDLE_VERSION' is below the canonical Sparkle floor '$CANONICAL_APP_BUILD' for '$VERSION'." >&2
echo "Set APP_BUILD explicitly only when you need a higher correction build." >&2
exit 1
fi
fi
if [[ "$RESUME_NOTARIZATION" == "1" && "$SKIP_DSYM" != "1" ]]; then
cp "$RECOVERY_DIR/symbols.zip" "$DSYM_ZIP"
elif [[ "$SKIP_DSYM" != "1" ]]; then
DSYM_PATHS=()
MISSING_DSYM_ARCHS=()
for arch in "${DSYM_ARCHS[@]}"; do
# Use the same SwiftPM output link as package-mac-app.sh; Xcode builds
# place products under out/Products/Release rather than a lowercase directory.
DSYM_FOR_ARCH="$BUILD_ROOT/$arch/$BUILD_CONFIG/$PRODUCT.dSYM"
if [[ -d "$DSYM_FOR_ARCH" ]]; then
DSYM_PATHS+=("$DSYM_FOR_ARCH")
else
MISSING_DSYM_ARCHS+=("$arch")
fi
done
if [[ "${#MISSING_DSYM_ARCHS[@]}" -gt 0 ]]; then
echo "Error: dSYM not found for architecture(s): ${MISSING_DSYM_ARCHS[*]} (set SKIP_DSYM=1 to skip symbols)" >&2
exit 1
fi
if [[ "${#DSYM_PATHS[@]}" -gt 0 ]]; then
TMP_DSYM="$ROOT_DIR/dist/$PRODUCT.dSYM"
rm -rf "$TMP_DSYM"
if [[ "${#DSYM_PATHS[@]}" -gt 1 ]]; then
copy_dsym_to_tmp "${DSYM_PATHS[0]}"
DWARF_OUT="$TMP_DSYM/Contents/Resources/DWARF/$PRODUCT"
DWARF_INPUTS=()
for dsym in "${DSYM_PATHS[@]}"; do
DWARF_INPUT="$dsym/Contents/Resources/DWARF/$PRODUCT"
if [[ ! -f "$DWARF_INPUT" ]]; then
echo "Error: missing DWARF binaries for dSYM merge (set SKIP_DSYM=1 to skip symbols)" >&2
cleanup_tmp_dsym
exit 1
fi
DWARF_INPUTS+=("$DWARF_INPUT")
done
if [[ "${#DWARF_INPUTS[@]}" -gt 1 ]]; then
if ! /usr/bin/lipo -create "${DWARF_INPUTS[@]}" -output "$DWARF_OUT"; then
cleanup_tmp_dsym
exit 1
fi
else
echo "Error: missing DWARF binaries for dSYM merge (set SKIP_DSYM=1 to skip symbols)" >&2
cleanup_tmp_dsym
exit 1
fi
else
copy_dsym_to_tmp "${DSYM_PATHS[0]}"
fi
echo "🧩 dSYM: $DSYM_ZIP"
rm -f "$DSYM_ZIP"
if ! ditto -c -k --keepParent "$TMP_DSYM" "$DSYM_ZIP"; then
rm -rf "$TMP_DSYM"
exit 1
fi
rm -rf "$TMP_DSYM"
else
echo "Error: dSYM not found (set SKIP_DSYM=1 to skip symbols)" >&2
exit 1
fi
fi
RETAINED_DMG="$RECOVERY_DIR/app.dmg"
if [[ "$SKIP_DMG" != "1" ]]; then
echo "💿 DMG: $DMG"
if [[ "$RESUME_NOTARIZATION" == "1" ]]; then
# Checkpoints are source-bound: older, partial checkpoints use their producer's script.
/usr/bin/codesign --verify --strict "$RETAINED_DMG"
if [[ "$NOTARIZE" == "1" && -n "${EXPECTED_DEVELOPER_TEAM_ID:-}" ]]; then
/usr/bin/codesign --verify --strict -R="anchor apple generic and certificate leaf[subject.OU] = \"${EXPECTED_DEVELOPER_TEAM_ID}\"" "$RETAINED_DMG"
fi
audit_retained_dmg_async_frames "$RETAINED_DMG"
else
DMG_SIGN_IDENTITY="${SIGN_IDENTITY:-}"
if [[ "$NOTARIZE" == "0" ]]; then
DMG_SIGN_IDENTITY="-"
fi
if [[ -z "$DMG_SIGN_IDENTITY" ]]; then
echo "Error: set SIGN_IDENTITY to sign the distribution DMG before checkpointing." >&2
exit 1
fi
"$ROOT_DIR/scripts/create-dmg.sh" "$APP" "$DMG"
echo "🔏 Signing DMG: $DMG"
if [[ "$DMG_SIGN_IDENTITY" == "-" ]]; then
/usr/bin/codesign --force --sign - --timestamp=none "$DMG"
else
/usr/bin/codesign --force --sign "$DMG_SIGN_IDENTITY" --timestamp "$DMG"
fi
fi
else
echo "💿 Skipping DMG (SKIP_DMG=1)"
fi
if [[ "$RESUME_NOTARIZATION" == "0" && ( "$NOTARIZE" == "1" || "$CHECKPOINT_ONLY" == "1" ) ]]; then
echo "📦 Notary zip: $NOTARY_ZIP"
mkdir "$RECOVERY_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$NOTARY_ZIP"
if [[ "$SKIP_DSYM" != "1" ]]; then
cp "$DSYM_ZIP" "$RECOVERY_DIR/symbols.zip"
fi
if [[ "$SKIP_DMG" != "1" ]]; then
mv "$DMG" "$RETAINED_DMG"
fi
python3 "$RECOVERY_HELPER" init "$RECOVERY_DIR" "$(git -C "$ROOT_DIR" rev-parse HEAD)" "$VERSION" "$BUNDLE_VERSION" "$SKIP_DMG" "$SKIP_DSYM"
RECOVERY_READY=1
fi
if [[ "$CHECKPOINT_ONLY" == "1" ]]; then
echo "✅ Signed packaging checkpoint ready: $RECOVERY_DIR"
exit 0
fi
if [[ "$NOTARIZE" == "1" ]]; then
STAPLE_APP_PATH="$APP" "$ROOT_DIR/scripts/notarize-mac-artifact.sh" --submission-file "$RECOVERY_DIR/app-submission.json" "$NOTARY_ZIP"
if [[ "$SKIP_DMG" != "1" ]]; then
"$ROOT_DIR/scripts/notarize-mac-artifact.sh" --submission-file "$RECOVERY_DIR/dmg-submission.json" "$RETAINED_DMG"
fi
fi
if [[ "$RECOVERY_READY" == "1" && "$SKIP_DMG" != "1" ]]; then
cp "$RETAINED_DMG" "$DMG"
fi
echo "📦 Zip: $ZIP"
rm -f "$ZIP"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
if [[ -n "$RESTORED_APP_DIR" ]]; then
rm -rf "$ROOT_DIR/dist/OpenClaw.app"
mv "$APP" "$ROOT_DIR/dist/OpenClaw.app"
fi
if [[ "$RECOVERY_READY" == "1" ]]; then
# Keep successful bytes available for workflow retention, then retire them
# only when the operator starts the next ordinary package build.
python3 "$RECOVERY_HELPER" complete "$RECOVERY_DIR"
fi