Files
2025-10-16 15:01:49 -04:00

304 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# All-in-one release script: build (external or pyinstaller), sign, package, and notarize.
#
# Usage examples:
# VERSION=2.0.0bp6 \
# SIGN_ID="Developer ID Application: Your Name (TEAMID)" \
# APPLE_ID="you@apple.com" APPLE_TEAM_ID="TEAMID" APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
# scripts/release.sh --variant external --embedded-python /path/to/Resources/python/bin/python3
#
# # With custom wheels and custom name
# VERSION=2.0.0bp6 BIN_NAME=my-cli \
# SIGN_ID="$CSC_NAME" APPLE_ID=... APPLE_TEAM_ID=... APPLE_APP_SPECIFIC_PASSWORD=... \
# scripts/release.sh --variant pyinstaller --mlx-wheel scripts/mlx-custom.whl --mlx-lm-wheel scripts/mlx-lm-custom.whl
#
# Flags:
# --variant external|pyinstaller (default: external)
# --embedded-python <path> (required for external variant)
# --mlx-wheel <path> override MLX wheel
# --mlx-lm-wheel <path> override MLX-LM wheel
# --skip-notarize sign only; do not notarize
# --entitlements <plist> optional entitlements for signing
# --disable-libval add disable-library-validation entitlement
#
# Env:
# VERSION, BIN_NAME version stamp and output name
# MLX_WHEEL, MLX_LM_WHEEL alternative to flags
# SIGN_ID or CSC_NAME codesign identity (Developer ID Application: … (TEAMID))
# APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD for notarytool
#
VARIANT=external
EMBED_PY=""
MLX_WHEEL_ARG=""
MLX_LM_WHEEL_ARG=""
SKIP_NOTARIZE=0
ENTITLEMENTS=""
DISABLE_LIBVAL=1
CPYTHON_URL_ARG=""
CPYTHON_DIR_ARG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--variant) VARIANT="${2:-}"; shift 2;;
--embedded-python) EMBED_PY="${2:-}"; shift 2;;
--cpython-url) CPYTHON_URL_ARG="${2:-}"; shift 2;;
--cpython-dir) CPYTHON_DIR_ARG="${2:-}"; shift 2;;
--mlx-wheel) MLX_WHEEL_ARG="${2:-}"; shift 2;;
--mlx-lm-wheel) MLX_LM_WHEEL_ARG="${2:-}"; shift 2;;
--skip-notarize) SKIP_NOTARIZE=1; shift;;
--entitlements) ENTITLEMENTS="${2:-}"; shift 2;;
--disable-libval) DISABLE_LIBVAL=1; shift;;
--no-disable-libval) DISABLE_LIBVAL=0; shift;;
*) echo "Unknown flag: $1" >&2; exit 1;;
esac
done
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
# Determine version: prefer env VERSION, otherwise package.json:version
if [[ -z "${VERSION:-}" ]]; then
PKG_VER="$(python3 - <<'PY' 2>/dev/null || true
import json, sys
try:
with open('package.json') as f:
print(json.load(f).get('version',''))
except Exception:
print('')
PY
)"
if [[ -n "$PKG_VER" ]]; then
export VERSION="$PKG_VER"
echo "[version] Using package.json version: $VERSION"
fi
fi
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
ART_DIR="$ROOT_DIR/artifacts"
mkdir -p "$ART_DIR"
# Determine signing identity early (used for pre-signing embedded Python)
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
ENV_ASSIGN=()
if [[ -n "${VERSION:-}" ]]; then ENV_ASSIGN+=(VERSION="$VERSION"); fi
ENV_ASSIGN+=(BIN_NAME="$APP_NAME")
if [[ -n "$MLX_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL_ARG"); fi
if [[ -n "${MLX_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL"); fi
if [[ -n "$MLX_LM_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL_ARG"); fi
if [[ -n "${MLX_LM_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL"); fi
case "$VARIANT" in
external)
# Resolve CPython source if not explicitly provided
CPYTHON_URL="${CPYTHON_URL_ARG:-${CPYTHON_URL:-}}"
CPYTHON_DIR="${CPYTHON_DIR_ARG:-${CPYTHON_DIR:-${ROOT_DIR}/python}}"
if [[ -z "$EMBED_PY" ]]; then
if [[ -n "$CPYTHON_URL" ]]; then
echo "[fetch] Downloading CPython from: $CPYTHON_URL"
TMP_TAR="$(mktemp -t cpython-XXXXXX).tar.gz"
curl -L "$CPYTHON_URL" -o "$TMP_TAR"
mkdir -p "$CPYTHON_DIR"
# Extract; detect if payload contains a top-level 'python' folder
TAR_TOP="$(tar -tzf "$TMP_TAR" | head -n1 | cut -d/ -f1)"
tar -xzf "$TMP_TAR" -C "$CPYTHON_DIR" --strip-components=0
rm -f "$TMP_TAR"
# Common layouts: either extracted files under CPYTHON_DIR directly or nested python/*
if [[ -x "$CPYTHON_DIR/bin/python3" ]]; then
EMBED_PY="$CPYTHON_DIR/bin/python3"
elif [[ -x "$CPYTHON_DIR/python/bin/python3" ]]; then
EMBED_PY="$CPYTHON_DIR/python/bin/python3"
else
# Try to find any python3 in subtree
EMBED_PY="$(find "$CPYTHON_DIR" -type f -path '*/bin/python3*' -perm -111 -print -quit 2>/dev/null || true)"
fi
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Unable to find python3 in extracted CPython at $CPYTHON_DIR" >&2; exit 1; }
echo "[fetch] Embedded Python resolved to: $EMBED_PY"
elif [[ -x "$ROOT_DIR/python/bin/python3" ]]; then
EMBED_PY="$ROOT_DIR/python/bin/python3"
fi
fi
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Embedded Python not found. Provide --embedded-python or CPYTHON_URL/CPYTHON_DIR or place it at ./python/bin/python3" >&2; exit 1; }
echo "[build] External-Python variant"
# Pre-sign embedded Python so we can execute it during build (and clear quarantine)
PYSRC_DIR="$(cd "$(dirname "$EMBED_PY")/.." && pwd)"
if [[ -d "$PYSRC_DIR" ]]; then
echo "[prep] Clearing quarantine and signing embedded Python at: $PYSRC_DIR"
scripts/clear_quarantine_python.sh "$PYSRC_DIR" || true
PRE_SIGN_ARGS=("$PYSRC_DIR")
if [[ "$DISABLE_LIBVAL" == "1" ]]; then PRE_SIGN_ARGS+=(--disable-libval); fi
if [[ -n "$ENTITLEMENTS" ]]; then PRE_SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
if [[ -n "$SIGN_ID_EFFECTIVE" ]]; then SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${PRE_SIGN_ARGS[@]}"; else echo "[prep] SIGN_ID not set; skipping pre-sign (may still run locally if quarantine is clear)"; fi
fi
env "${ENV_ASSIGN[@]}" EMBEDDED_PYTHON="$EMBED_PY" CLEAN=1 SKIP_TAR=1 scripts/build_external_python.sh
BUNDLE_DIR="$ROOT_DIR/dist-ext-python/$APP_NAME"
# Include the embedded Python inside the bundle so the launcher can auto-resolve SELF_DIR/python/bin/python3
if [[ -d "$PYSRC_DIR" ]]; then
echo "[bundle] Embedding Python into bundle at: $BUNDLE_DIR/python"
rsync -a --delete "$PYSRC_DIR/" "$BUNDLE_DIR/python/"
fi
# Replace shell launcher with a tiny Mach-O wrapper so the process name shows as "$APP_NAME"
echo "[bundle] Building native launcher to keep process name as $APP_NAME"
cat > "$BUNDLE_DIR/.launcher.c" <<'C'
#include <spawn.h>
#include <sys/wait.h>
#include <mach-o/dyld.h>
#include <libgen.h>
#include <limits.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <signal.h>
extern char **environ;
static volatile sig_atomic_t got_sig = 0;
static volatile pid_t child_pid = -1;
static void dirname_of(const char *path, char *out, size_t outsz) {
strncpy(out, path, outsz - 1); out[outsz-1] = '\0';
char *d = dirname(out);
if (d != out) strncpy(out, d, outsz - 1);
}
static void handle_signal(int sig) {
got_sig++;
pid_t pid = child_pid;
if (pid > 0) {
if (got_sig == 1) kill(pid, sig == SIGINT ? SIGINT : SIGTERM);
else kill(pid, SIGKILL);
}
}
int main(int argc, char *argv[]) {
// Resolve executable path
char execPath[PATH_MAX]; uint32_t size = sizeof(execPath);
if (_NSGetExecutablePath(execPath, &size) != 0) return 127;
char bundleRoot[PATH_MAX]; dirname_of(execPath, bundleRoot, sizeof(bundleRoot));
// Construct embedded python and vendor paths
char pyPath[PATH_MAX]; snprintf(pyPath, sizeof(pyPath), "%s/python/bin/python3", bundleRoot);
char vendorPath[PATH_MAX]; snprintf(vendorPath, sizeof(vendorPath), "%s/_vendor", bundleRoot);
// Prepare environment
setenv("MLXK_PYTHON", pyPath, 1);
setenv("RESOURCES_PATH", bundleRoot, 1);
setenv("PYTHONNOUSERSITE", "1", 0);
// Disable supervised uvicorn subprocess; run server in-process
setenv("MLXK2_SUPERVISE", "0", 1);
// Ensure any `python3` shebang or /usr/bin/env python3 resolves to embedded one
const char *old_path = getenv("PATH");
size_t newlen = strlen(bundleRoot) + strlen("/python/bin") + 1 + (old_path ? strlen(old_path) : 0) + 1;
char *newpath = (char*)malloc(newlen);
if (newpath) {
if (old_path && *old_path)
snprintf(newpath, newlen, "%s/python/bin:%s", bundleRoot, old_path);
else
snprintf(newpath, newlen, "%s/python/bin", bundleRoot);
setenv("PATH", newpath, 1);
free(newpath);
}
// Help Python find its stdlib explicitly
char pyHome[PATH_MAX]; snprintf(pyHome, sizeof(pyHome), "%s/python", bundleRoot);
setenv("PYTHONHOME", pyHome, 1);
setenv("PYTHONEXECUTABLE", pyPath, 1);
// Prepend vendor to PYTHONPATH
const char *pp = getenv("PYTHONPATH");
if (pp && *pp) {
size_t len = strlen(vendorPath) + 1 + strlen(pp) + 1;
char *buf = (char*)malloc(len);
if (!buf) return 127;
snprintf(buf, len, "%s:%s", vendorPath, pp);
setenv("PYTHONPATH", buf, 1);
free(buf);
} else {
setenv("PYTHONPATH", vendorPath, 1);
}
// Build argv for python: python -m mlxk2.cli [args...]
int n = argc + 3; // python, -m, module, args...
char **pargv = (char**)calloc(n + 1, sizeof(char*));
if (!pargv) return 127;
pargv[0] = pyPath;
pargv[1] = "-m";
pargv[2] = "mlxk2.cli";
for (int i = 1; i < argc; ++i) pargv[2 + i] = argv[i];
pargv[n] = NULL;
// Install signal handlers to forward to child
struct sigaction sa; memset(&sa, 0, sizeof(sa));
sa.sa_handler = handle_signal; sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
// Spawn python child; keep this process name as launcher
pid_t pid; int rc = posix_spawn(&pid, pyPath, NULL, NULL, pargv, environ);
if (rc != 0) return rc;
child_pid = pid;
// Wait and propagate exit
int status = 0; while (1) {
pid_t w = waitpid(pid, &status, 0);
if (w == -1) continue; // interrupted by signal
break;
}
if (WIFEXITED(status)) return WEXITSTATUS(status);
if (WIFSIGNALED(status)) return 128 + WTERMSIG(status);
return 0;
}
C
clang -O2 -Wall -mmacosx-version-min=11 -o "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/.launcher.c"
mv -f "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/$APP_NAME"
rm -f "$BUNDLE_DIR/.launcher.c"
;;
pyinstaller)
echo "[build] PyInstaller variant"
env "${ENV_ASSIGN[@]}" scripts/build.sh
BUNDLE_DIR="$ROOT_DIR/dist/$APP_NAME"
;;
*)
echo "Unknown variant: $VARIANT" >&2; exit 1;
;;
esac
[[ -d "$BUNDLE_DIR" ]] || { echo "ERROR: bundle directory not found: $BUNDLE_DIR" >&2; exit 1; }
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
[[ -n "$SIGN_ID_EFFECTIVE" ]] || { echo "ERROR: set SIGN_ID or CSC_NAME to your codesigning identity" >&2; exit 1; }
SIGN_ARGS=("$BUNDLE_DIR")
if [[ -n "$ENTITLEMENTS" ]]; then SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
if [[ "$DISABLE_LIBVAL" == "1" ]]; then SIGN_ARGS+=(--disable-libval); fi
echo "[sign] Codesigning bundle with: $SIGN_ID_EFFECTIVE"
SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${SIGN_ARGS[@]}"
TGZ_NAME_BASE="$APP_NAME"; [[ -n "${VERSION:-}" ]] && TGZ_NAME_BASE+="-${VERSION}"
TGZ_PATH="$ART_DIR/${TGZ_NAME_BASE}.tgz"
# Notarize using a temporary zip (Apple Notary Service prefers zip input).
if [[ "$SKIP_NOTARIZE" == "0" ]]; then
TMP_ZIP="$(mktemp -t ${APP_NAME}-notary-XXXXXX).zip"
echo "[notary] Submitting for notarization via temporary zip"
SIGN_ID="$SIGN_ID_EFFECTIVE" \
APPLE_ID="${APPLE_ID:-}" APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" APPLE_APP_SPECIFIC_PASSWORD="${APPLE_APP_SPECIFIC_PASSWORD:-}" \
scripts/sign_and_notarize.sh "$BUNDLE_DIR" --notarize --zip "$TMP_ZIP"
rm -f "$TMP_ZIP" || true
fi
# Always produce TGZ for distribution
echo "[package] Creating tgz: $TGZ_PATH"
tar -C "$(dirname "$BUNDLE_DIR")" -czf "$TGZ_PATH" "$APP_NAME"
echo "[done] Release ready"
echo "- Bundle: $BUNDLE_DIR"
echo "- TGZ: $TGZ_PATH"