From 43563713ae6b19b5bb2e6a79427ff18472bd3eab Mon Sep 17 00:00:00 2001 From: Pushkinist <4850452+Pushkinist@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:19:47 +0700 Subject: [PATCH] feat(release): add Homebrew bottle build+publish flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/release/build_bottle.sh: builds a bottle from the installed rmlx keg via `brew bottle --json --root-url`, renames the local double-dash filename to the remote single-dash expected by Homebrew, prints the bottle do block to paste into the formula, and prints the gh release upload command. shellcheck-clean. - Makefile: adds `make bottle` target wired to the new script; adds it to the .PHONY list alongside the other release targets. - docs/RELEASING.md: new step 8 in the cut-a-release flow covering the full bottle lifecycle (brew install --build-bottle → make bottle → upload → paste bottle do block → commit → tap-sync); updated Files table; added Homebrew bottle verification section. Formula approach: no bottle do block is committed until a real bottle artifact exists on the Release. The source-build path (depends_on "rust" => :build) remains the fallback for any macOS version without a matching bottle, so the formula stays installable at all times. The bottle do block is added in a separate formula-update commit at release time, after the bottle tar.gz is uploaded to the GitHub Release. --- Makefile | 5 +- docs/RELEASING.md | 83 ++++++++++++++++++- scripts/release/build_bottle.sh | 136 ++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 4 deletions(-) create mode 100755 scripts/release/build_bottle.sh diff --git a/Makefile b/Makefile index e823e4e..af293d4 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ PROF_GEN ?= 500 AUDIT_IGNORES := --ignore RUSTSEC-2024-0436 --ignore RUSTSEC-2025-0119 .PHONY: help build check test fmt fmt-check lint audit deny precommit hooks \ - ci ci-metrics tag release-package release-sha release-sign tap-sync \ + ci ci-metrics tag release-package release-sha release-sign bottle tap-sync \ clean serve chat info logs-tail metrics-summary \ metrics-init metrics-doctor metrics-doctor-fix metrics-export \ metrics-backup metrics-replay-pending metrics-prompts-sync \ @@ -181,6 +181,9 @@ release-sha: ## print sha256 of the v GitHub source tarball (append --w release-sign: ## keyless cosign-sign dist/rmlx-v-...tar.gz -> .cosign.bundle (needs cosign + browser OIDC) bash scripts/release/sign_artifact.sh +bottle: ## build a Homebrew bottle from the installed rmlx keg (run after brew install --build-bottle) + bash scripts/release/build_bottle.sh + tap-sync: ## copy packaging/homebrew/rmlx.rb into the homebrew-rmlx tap and push bash scripts/release/sync_tap.sh diff --git a/docs/RELEASING.md b/docs/RELEASING.md index adbbc9d..bd98994 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -50,7 +50,67 @@ crates are `publish = false`). There is no separate `VERSION` file. your authenticated identity + the public Rekor log — real provenance for the prebuilt binary. Consumer-side verification is in "Verify both install paths". -8. **Formula url + sha256:** run `make release-sha` (or +8. **Build + publish the Homebrew bottle** (binary install channel — no Rust + toolchain required for users): + + a. **Install the keg from source** on the release machine (the same + Apple-Silicon Mac used for the binary artifact above): + ```sh + brew install --build-bottle packaging/homebrew/rmlx.rb + ``` + This is a full source build. It may take a few minutes. `brew install + --build-bottle` is required because a normal `brew install` may silently + reuse a cached bottle from a previous release, producing a wrong keg. + + b. **Build the bottle and upload it:** + ```sh + make bottle # runs scripts/release/build_bottle.sh + ``` + The script: + - Runs `brew bottle --json --root-url=https://github.com/Pushkinist/rMLX/releases/download/v`. + - Renames the local `rmlx--..bottle.tar.gz` to the remote + single-dash name `rmlx-..bottle.tar.gz` (Homebrew's + intentional local/remote naming split — the remote asset must use + single-dash or `brew install` gets a 404). + - Prints the `bottle do … end` block to paste into the formula. + - Prints the exact `gh release upload` command. + + Run the printed upload command: + ```sh + gh release upload v dist/rmlx-..bottle.tar.gz + ``` + + c. **Paste the `bottle do` block into `packaging/homebrew/rmlx.rb`.** + The script prints the block with the correct `root_url`, `cellar`, OS + tag, and sha256. Insert it immediately after the `head` line, before + the `depends_on` lines: + ```ruby + bottle do + root_url "https://github.com/Pushkinist/rMLX/releases/download/v" + sha256 cellar: :any_skip_relocation, arm64_tahoe: "" + end + ``` + The `depends_on "rust" => :build` and `depends_on "mlx-c"` lines remain + unchanged. When a `bottle do` block is present, `brew install` fetches + the binary directly; if the bottle is unavailable for the user's macOS + version Homebrew falls back to the source build automatically. + + d. **Commit the formula update** via a PR (main is ruleset-protected): + ```sh + git add packaging/homebrew/rmlx.rb + git commit -m "chore(release): add bottle for v" + ``` + Open the PR and merge it; then continue to step 9 (formula url+sha256). + + > **Clean-machine verification (optional but recommended):** + > On a machine without a local rmlx keg: + > - `brew tap Pushkinist/rmlx && brew trust Pushkinist/rmlx && brew install rmlx` — + > should download the prebuilt bottle, not compile from source (confirm with + > `brew install --verbose rmlx` — the word "Bottled" appears in output). + > - `brew uninstall mlx-c && brew install rmlx` — should fail cleanly with a + > dependency error before attempting any download or compile. + +9. **Formula url + sha256:** run `make release-sha` (or `bash scripts/release/source_sha256.sh --write`) — it patches **both** the `url` line in `packaging/homebrew/rmlx.rb` to the new `v` tag tarball **and** the `sha256`. @@ -60,8 +120,8 @@ crates are `publish = false`). There is no separate `VERSION` file. > re-fetch the archive 2-3× (`curl -fsSL .../archive/refs/tags/v.tar.gz > | shasum -a 256`) and confirm the digest is stable before trusting it. Commit the formula bump via a PR (`main` is ruleset-protected; see below). -9. **Publish the tap:** `make tap-sync` (copies the formula into - `Pushkinist/homebrew-rmlx` as `Formula/rmlx.rb` and pushes). +10. **Publish the tap:** `make tap-sync` (copies the formula into + `Pushkinist/homebrew-rmlx` as `Formula/rmlx.rb` and pushes). ## Dependency-bump PRs (Dependabot) @@ -88,6 +148,22 @@ fix (the migration commit is otherwise only reachable via reflog). ## Verify both install paths +**Homebrew bottle (binary — no compile):** +```sh +brew tap Pushkinist/rmlx +brew trust Pushkinist/rmlx +brew install rmlx # fetches bottle, does NOT invoke cargo +brew test rmlx +rmlx --version +``` +Confirm no source build occurred: `brew install --verbose rmlx` shows "Bottled" +in the output. To confirm the clean mlx-c dependency failure: +```sh +brew uninstall mlx-c +brew install rmlx # fails with dependency error, not a dyld crash +brew install mlx-c # reinstate +``` + **Prebuilt binary (from the Release):** ```sh brew install mlx-c @@ -133,6 +209,7 @@ brew audit --strict --new rmlx | `CHANGELOG.md` | Durable release notes (Keep a Changelog); body source | | `packaging/homebrew/rmlx.rb` | Canonical formula (source of truth) | | `scripts/release/package_binary.sh` | Build + bundle the binary tarball | +| `scripts/release/build_bottle.sh` | Build the Homebrew bottle from an installed keg, rename, and print upload + formula instructions | | `scripts/release/source_sha256.sh` | Compute / patch the formula source sha256 | | `scripts/release/sync_tap.sh` | Push the formula to the tap repo | | `scripts/release/changelog_section.sh` | Print one version's CHANGELOG section | diff --git a/scripts/release/build_bottle.sh b/scripts/release/build_bottle.sh new file mode 100755 index 0000000..156db20 --- /dev/null +++ b/scripts/release/build_bottle.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# build_bottle.sh — build a Homebrew bottle from the installed rmlx keg and +# prepare it for upload to the GitHub Release. +# +# What this script does: +# 1. Verifies rmlx is installed in Homebrew (brew install --build-bottle +# must have been run first — see docs/RELEASING.md). +# 2. Runs `brew bottle --json --root-url=` to produce: +# dist/.bottle.tar.gz +# dist/rmlx--..bottle.json +# 3. Renames the local file to the remote filename (single-dash naming — +# Homebrew intentionally uses double-dash locally; the remote asset must +# use single-dash so brew install resolves it correctly). +# 4. Prints the `bottle do ... end` DSL block to paste into +# packaging/homebrew/rmlx.rb, with root_url already set. +# 5. Prints the exact `gh release upload` command to attach the bottle. +# +# Prerequisites: +# - rmlx installed via `brew install --build-bottle packaging/homebrew/rmlx.rb` +# on the same macOS major version you want to bottle. +# - A published GitHub Release for the current version tag must already +# exist (step 6 in docs/RELEASING.md) so the root_url is valid at +# install time. +# - `brew` and `jq` available in PATH. +# +# Usage: +# bash scripts/release/build_bottle.sh +# bash scripts/release/build_bottle.sh --root-url https://github.com/Pushkinist/rMLX/releases/download/v1.2.3 +# +# The --root-url flag overrides the default (derived from Cargo.toml version). +# Use it when you need to bottle against a future or patched release URL. + +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +# ── prerequisites ────────────────────────────────────────────────────────────── +command -v brew >/dev/null 2>&1 || { echo "error: brew not found" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "error: jq not found (brew install jq)" >&2; exit 1; } + +# Apple Silicon only — the binary links Metal MLX. +[ "$(uname -m)" = "arm64" ] || { echo "error: must run on Apple Silicon (arm64)" >&2; exit 1; } + +# ── version ──────────────────────────────────────────────────────────────────── +VER=$(awk -F'"' '/^version = /{print $2; exit}' Cargo.toml) +[ -n "$VER" ] || { echo "error: could not read version from Cargo.toml" >&2; exit 1; } + +# ── root-url (GitHub Release download root) ──────────────────────────────────── +OWNER_REPO="Pushkinist/rMLX" +DEFAULT_ROOT_URL="https://github.com/${OWNER_REPO}/releases/download/v${VER}" + +ROOT_URL="${DEFAULT_ROOT_URL}" +if [ "${1:-}" = "--root-url" ]; then + ROOT_URL="${2:?error: --root-url requires a URL argument}" +fi + +echo "==> building bottle for rmlx v${VER}" +echo " root_url: ${ROOT_URL}" + +# ── check keg is installed ───────────────────────────────────────────────────── +brew list rmlx >/dev/null 2>&1 || { + echo "" >&2 + echo "error: rmlx is not installed in Homebrew." >&2 + echo "" >&2 + echo "Install it first with:" >&2 + echo " brew install --build-bottle packaging/homebrew/rmlx.rb" >&2 + echo "" >&2 + echo "Then re-run this script." >&2 + exit 1 +} + +# ── output directory ─────────────────────────────────────────────────────────── +mkdir -p dist + +# ── brew bottle ──────────────────────────────────────────────────────────────── +# --json writes metadata to a JSON file (not into the formula). +# --root-url sets the root_url field in the JSON / bottle do block. +# --no-rebuild suppresses the rebuild counter (starts clean for every release). +# --force-core-tap allows bottling a non-core-tap formula. +# Output files land in the current directory; we move them into dist/ below. +echo "==> running brew bottle" +brew bottle \ + --json \ + --root-url="${ROOT_URL}" \ + --no-rebuild \ + --force-core-tap \ + rmlx + +# ── locate generated files ───────────────────────────────────────────────────── +# brew bottle emits files to $PWD. Local filename uses double-dash (intentional +# Homebrew quirk); the remote asset must use single-dash. +LOCAL_TAR=$(find . -maxdepth 1 -name 'rmlx--*.bottle.tar.gz' -print 2>/dev/null | sort | tail -1 | sed 's|^\./||') +JSON_FILE=$(find . -maxdepth 1 -name 'rmlx--*.bottle.json' -print 2>/dev/null | sort | tail -1 | sed 's|^\./||') + +[ -n "$LOCAL_TAR" ] || { echo "error: brew bottle did not produce a .bottle.tar.gz file" >&2; exit 1; } +[ -n "$JSON_FILE" ] || { echo "error: brew bottle did not produce a .bottle.json file" >&2; exit 1; } + +# ── rename local → remote filename (double-dash → single-dash) ───────────────── +# The JSON tab contains both fields: +# local_filename → rmlx--0.2.3.arm64_tahoe.bottle.tar.gz +# filename → rmlx-0.2.3.arm64_tahoe.bottle.tar.gz +REMOTE_TAR=$(jq -r '.rmlx.bottle.tags | to_entries[0].value.filename' "$JSON_FILE" 2>/dev/null || echo "") +if [ -z "$REMOTE_TAR" ]; then + # Fallback: replace first occurrence of '--' with '-' + REMOTE_TAR="${LOCAL_TAR/--/-}" +fi + +mv "$LOCAL_TAR" "dist/${REMOTE_TAR}" +mv "$JSON_FILE" "dist/${JSON_FILE}" + +echo "==> bottle: dist/${REMOTE_TAR}" +echo "==> json: dist/${JSON_FILE}" + +# ── print bottle do block ────────────────────────────────────────────────────── +OS_TAG=$(jq -r '.rmlx.bottle.tags | keys[0]' "dist/${JSON_FILE}" 2>/dev/null || echo "arm64_tahoe") +SHA256=$(jq -r ".rmlx.bottle.tags[\"${OS_TAG}\"].sha256" "dist/${JSON_FILE}" 2>/dev/null || echo "") +CELLAR=$(jq -r ".rmlx.bottle.tags[\"${OS_TAG}\"].cellar" "dist/${JSON_FILE}" 2>/dev/null || echo ":any_skip_relocation") + +echo "" +echo "==> paste this bottle do block into packaging/homebrew/rmlx.rb" +echo " (replace any existing bottle do ... end block)" +echo "" +echo " bottle do" +echo " root_url \"${ROOT_URL}\"" +echo " sha256 cellar: ${CELLAR}, ${OS_TAG}: \"${SHA256}\"" +echo " end" +echo "" + +# ── upload instructions ──────────────────────────────────────────────────────── +echo "==> upload the bottle to the GitHub Release:" +echo " gh release upload v${VER} dist/${REMOTE_TAR}" +echo "" +echo "==> then commit the updated formula and sync the tap:" +echo " make tap-sync" +echo "" +echo "See docs/RELEASING.md for the full bottle release flow."