diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c30da3..a1fbfe5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,10 @@ permissions: contents: write jobs: release: + # macOS 26 or later: each runtime imports MLX to check itself, and the default build's + # Metal 4 library does not load on earlier macOS. runs-on: macos-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 @@ -19,10 +21,15 @@ jobs: version=${GITHUB_REF_NAME#v} uv python install 3.12.12 python_root="$(uv python dir)/cpython-3.12.12-macos-aarch64-none" - "$python_root/bin/python3.12" packaging/build-mlx-runtime.py --python-root "$python_root" --output .build/bundle-mlx - python3 packaging/package.py --runtime-dir .build/bundle-mlx --manifest packaging/flint-int4-mlx.json \ - --version "$version" --output dist/rec-darwin-arm64 - test "$(dist/rec-darwin-arm64 --version)" = "rec $version" + # install.sh picks the -macos15 build on macOS before 26. + for target in 26.0:rec-darwin-arm64 15.0:rec-darwin-arm64-macos15; do + macos=${target%%:*} artifact=${target#*:} + "$python_root/bin/python3.12" packaging/build-mlx-runtime.py --python-root "$python_root" \ + --macos "$macos" --output ".build/bundle-mlx-$macos" + python3 packaging/package.py --runtime-dir ".build/bundle-mlx-$macos" --manifest packaging/flint-int4-mlx.json \ + --version "$version" --output "dist/$artifact" + test "$("dist/$artifact" --version)" = "rec $version" + done - name: Publish env: GH_TOKEN: ${{ github.token }} @@ -38,4 +45,5 @@ jobs: ;; esac gh release create "$GITHUB_REF_NAME" "${notes[@]}" --verify-tag --generate-notes \ - --title "rec $GITHUB_REF_NAME" dist/rec-darwin-arm64 dist/rec-darwin-arm64.meta + --title "rec $GITHUB_REF_NAME" dist/rec-darwin-arm64 dist/rec-darwin-arm64.meta \ + dist/rec-darwin-arm64-macos15 dist/rec-darwin-arm64-macos15.meta diff --git a/README.md b/README.md index 0e08a2a..03ea6e1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ excluded.

## Requirements - Apple Silicon Mac with an M4 Pro, M4 Max, M5 Pro, or M5 Max +- macOS 15 Sequoia or later - At least 48 GB unified memory - About 30 GB free disk space - [Node.js 22](https://nodejs.org/en/download) and npm for pi diff --git a/install.sh b/install.sh index 35c27f7..8be25fd 100644 --- a/install.sh +++ b/install.sh @@ -24,7 +24,11 @@ rec_install() ( esac done [ "$(uname -s)" = Darwin ] && [ "$(uname -m)" = arm64 ] || fail 'rec requires an Apple Silicon Mac' - artifact=rec-darwin-arm64 + artifact=rec-darwin-arm64 variant='' + # The default build's MLX runtime uses Metal 4, which needs macOS 26. + macos=$(sw_vers -productVersion) || fail 'could not read the macOS version' + [[ "$macos" =~ ^[0-9]+ ]] || fail "unrecognized macOS version: $macos" + if [ "${BASH_REMATCH[0]}" -lt 26 ]; then variant=-macos15; artifact=$artifact$variant; fi version=${REC_VERSION:-} version=${version#v} [ -z "$version" ] || [[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail 'invalid REC_VERSION' @@ -77,7 +81,7 @@ rec_install() ( begin 'Checking this Mac' doctor=$("$tmp/$artifact" doctor 2>&1) || { printf '%s' "$erase"; printf '%s\n' "$doctor" >&2; fail 'this Mac does not meet the requirements above'; } done_step 'This Mac meets the requirements' - release="$data/releases/$version" + release="$data/releases/$version$variant" if [ -e "$release" ]; then [ -f "$release/.managed" ] && [ -f "$release/rec" ] || fail 'release directory is not managed' installed_hash=$(shasum -a 256 "$release/rec"); installed_hash=${installed_hash%% *} diff --git a/packaging/build-mlx-runtime.py b/packaging/build-mlx-runtime.py index 13234d3..dd1aa3c 100644 --- a/packaging/build-mlx-runtime.py +++ b/packaging/build-mlx-runtime.py @@ -6,6 +6,8 @@ import argparse import json +import os +import re import shutil import subprocess import tempfile @@ -26,9 +28,24 @@ def main(): ) parser.add_argument("--output", type=Path, required=True, help="new directory for the runtime") parser.add_argument("--uv", default="uv", help="uv executable (default: %(default)s)") + parser.add_argument( + "--macos", + metavar="VERSION", + help="oldest macOS the runtime supports, such as 15.0 (default: this Mac's version)", + ) args = parser.parse_args() if args.output.exists(): parser.error("output must be a new directory") + if args.macos and not re.fullmatch(r"\d+\.\d+", args.macos): + parser.error("--macos must look like 15.0") + # MLX publishes a wheel per macOS release; the macOS 26 metallib uses Metal 4 and fails to + # load on macOS 15. The deployment target selects those wheels and sets the native + # extension's minimum OS. + env = dict(os.environ) + platform = [] + if args.macos: + env["MACOSX_DEPLOYMENT_TARGET"] = args.macos + platform = ["--python-platform", "aarch64-apple-darwin"] source_python = args.python_root / "bin/python3.12" information = json.loads( subprocess.check_output( @@ -57,6 +74,7 @@ def main(): str(wheels), str(ENGINE), ], + env=env, check=True, ) (wheel,) = wheels.glob("rec_mlx-*.whl") @@ -78,10 +96,12 @@ def main(): "--python", str(python), "--break-system-packages", + *platform, "--only-binary", ":all:", str(wheel), ], + env=env, check=True, ) launcher = stage / "rec-mlx" @@ -96,6 +116,7 @@ def main(): "os": "darwin", "arch": "arm64", "backend": "metal", + **({"macos": args.macos} if args.macos else {}), }, indent=2, ) diff --git a/tests/installer_test.py b/tests/installer_test.py index 8d22788..ea4b092 100644 --- a/tests/installer_test.py +++ b/tests/installer_test.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "install.sh" ARTIFACT = "rec-darwin-arm64" +ARTIFACTS = [ARTIFACT, ARTIFACT + "-macos15"] def fake_rec(version): @@ -30,15 +31,17 @@ def fake_rec(version): def publish(directory, version, reported=None, bad_hash=False): directory.mkdir(parents=True, exist_ok=True) - body = fake_rec(reported or version).encode() - (directory / ARTIFACT).write_bytes(body) - digest = "0" * 64 if bad_hash else hashlib.sha256(body).hexdigest() - (directory / (ARTIFACT + ".meta")).write_text(f"{digest} {len(body)}\n") + for artifact in ARTIFACTS: + body = fake_rec(reported or version).encode() + f"# {artifact}\n".encode() + (directory / artifact).write_bytes(body) + digest = "0" * 64 if bad_hash else hashlib.sha256(body).hexdigest() + (directory / (artifact + ".meta")).write_text(f"{digest} {len(body)}\n") FIXTURES = { "uname": '#!/bin/sh\ncase "$1" in -s) echo "${TEST_OS:-Darwin}";; -m) echo "${TEST_ARCH:-arm64}";; esac\n', "curl": "#!/bin/sh\nexit 22\n", + "sw_vers": '#!/bin/sh\n[ "$1" = -productVersion ] && echo "${TEST_MACOS:-26.1}"\n', } @@ -58,7 +61,10 @@ def main(): publish(releases / "download/v0.6.0", "0.6.0", bad_hash=True) bins = root / "bin" offline = root / "offline" - for directory, names in [(bins, ["uname"]), (offline, ["uname", "curl"])]: + for directory, names in [ + (bins, ["uname", "sw_vers"]), + (offline, ["uname", "sw_vers", "curl"]), + ]: directory.mkdir() for name in names: (directory / name).write_text(FIXTURES[name]) @@ -134,6 +140,17 @@ def installed(home): assert not (home / "running").exists() assert installed(home).parent.name == "0.3.0" + home, env = new_home({"TEST_MACOS": "15.8.1"}) + install(env) + sequoia = installed(home) + assert sequoia == (home / ".local/share/rec/releases/0.3.0-macos15/rec").resolve(), sequoia + assert sequoia.read_text().endswith("# rec-darwin-arm64-macos15\n") + # After upgrading to macOS 26, the same version installs the default build beside it. + install(dict(env, TEST_MACOS="26.0")) + assert installed(home).parent.name == "0.3.0" + assert installed(home).read_text().endswith("# rec-darwin-arm64\n") + assert "unrecognized macOS" in install(dict(env, TEST_MACOS="beta"), ok=False).stderr + home, env = new_home() install(env, "--serve") assert (home / "serve.log").read_text() == "serve\n" @@ -157,7 +174,7 @@ def installed(home): server.shutdown() server.server_close() print( - "Installer: public default URL, pins, idempotence, checksum/version/device rejection, running-server stop, --serve, platform checks and collision preservation passed" + "Installer: public default URL, pins, idempotence, checksum/version/device rejection, running-server stop, --serve, platform checks, macOS 15 build selection and collision preservation passed" )