From 7dcc907ee8135730f51a50014440d1375a5c4385 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Wed, 28 Oct 2026 10:41:25 +0100 Subject: [PATCH] Ship a macOS 15 build alongside the macOS 26 build MLX publishes a wheel per macOS release. The release job runs on macOS 26, so the runtime got the macOS 26 mlx-metal wheel, whose metallib uses Metal language version 4.0. On macOS 15 Sequoia, loading it fails with "This library is using language version 4.0 which is not supported on this OS" before Flint loads (#38). build-mlx-runtime.py takes --macos, which sets MACOSX_DEPLOYMENT_TARGET and resolves wheels for that target. That selects the matching MLX wheels and sets the native grammar extension's minimum OS. The release now builds two binaries: rec-darwin-arm64 for macOS 26 and later, unchanged in name, and rec-darwin-arm64-macos15. install.sh picks one with sw_vers, and rec update follows because it runs the release's install.sh. The macOS 15 build installs to its own release directory, so the same version can switch to the default build after a macOS upgrade. Keeping a separate macOS 26 build preserves MLX's Metal 4 kernels for the M5 Neural Accelerators, which the macOS 15 metallib lacks. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 20 ++++++++++++++------ README.md | 1 + install.sh | 8 ++++++-- packaging/build-mlx-runtime.py | 21 +++++++++++++++++++++ tests/installer_test.py | 29 +++++++++++++++++++++++------ 5 files changed, 65 insertions(+), 14 deletions(-) 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" )