diff --git a/.claude/distribution.md b/.claude/distribution.md index 0842f69..99d6a5a 100644 --- a/.claude/distribution.md +++ b/.claude/distribution.md @@ -196,14 +196,84 @@ Tag format: `v0.1.0` CFBundleVersion = `$GITHUB_RUN_NUMBER` (integer, auto-increments) CFBundleShortVersionString = tag without `v` prefix (e.g. `0.1.0`) -## Homebrew Tap (v0.2+) - -```ruby -class MacMlx < Formula - desc "Native macOS LLM inference powered by MLX" - homepage "https://github.com/magicnight/mac-mlx" - url "https://github.com/magicnight/mac-mlx/releases/download/v0.1.0/macMLX-v0.1.0.dmg" - sha256 "..." - version "0.1.0" -end +## Homebrew Tap (CLI) + +The `macmlx` CLI ships through a Homebrew tap so developers can install +it with one command: + +```bash +brew tap magicnight/mac-mlx +brew install macmlx +``` + +The GUI app stays on the GitHub Releases DMG path — Homebrew is CLI-only +to avoid dragging cask packaging into the same pipeline. + +### Pieces + +| Where | What | +|-------|------| +| `Formula/macmlx.rb` (this repo) | Source-of-truth template with `@@VERSION@@`, `@@URL@@`, `@@SHA256@@` placeholders. | +| `scripts/package-cli.sh` | Builds Release `macmlx`, strips it, packages `dist/macmlx-${TAG}-arm64.tar.gz` + `.sha256`. | +| `scripts/render-formula.sh` | Fills the template with the rendered tarball URL + sha and writes `dist/macmlx.rb`. | +| `.github/workflows/release.yml` | On `v*.*.*` tag: runs both scripts, attaches tarball + rendered formula to the Release, and (if `HOMEBREW_TAP_TOKEN` is set) pushes the formula to the tap repo. | +| `magicnight/homebrew-mac-mlx` (separate repo) | Tap repo Homebrew clones. Holds `Formula/macmlx.rb`. Other than that, it's empty. | + +### Tarball layout + +The tarball contains a single top-level executable so the formula's +`bin.install "macmlx"` works without unpacking nested directories: + +``` +macmlx-v0.3.8-arm64.tar.gz +└── macmlx (Mach-O arm64, stripped, dynamic Swift stdlib) ``` + +### Bootstrapping the tap repo (one-time) + +1. Create an empty public repo `magicnight/homebrew-mac-mlx` (the + `homebrew-` prefix is mandatory; Homebrew uses it to resolve + `brew tap magicnight/mac-mlx`). +2. Add a minimal `README.md` explaining the install command. +3. Cut a release in this repo (`git tag v0.X.Y && git push --tags`). + The release workflow will produce `dist/macmlx.rb` and attach it to + the GitHub Release. +4. Either copy `macmlx.rb` into `Formula/macmlx.rb` of the tap repo + manually, or: +5. Generate a fine-grained GitHub PAT with `Contents: Read+Write` on + `magicnight/homebrew-mac-mlx`, store it as `HOMEBREW_TAP_TOKEN` in + this repo's secrets, and re-run the release. The "Publish formula to + Homebrew tap" step will commit the formula automatically on every + subsequent release. + +### Why a separate tap repo? + +Homebrew's tap discovery is hard-coded: `brew tap /` +expects `github.com//homebrew-`. Nesting the formula inside +the main repo wouldn't be discoverable. Keeping the tap repo otherwise +empty means the formula stays a single sourced-from-here artifact — +no drift risk, no separate test setup. + +### Sanity check before release + +```bash +# Smoke-test the renderer against the current tag. +GITHUB_REF_NAME=v0.0.0-dev \ +MACMLX_CLI_SHA256=0000000000000000000000000000000000000000000000000000000000000000 \ + ./scripts/render-formula.sh +cat dist/macmlx.rb + +# Lint the rendered formula. Requires `brew` locally. +brew audit --strict --new dist/macmlx.rb || true +``` + +`brew audit --new` only warns; the formula doesn't ship into +homebrew-core so we accept its tap-formula leniencies. + +## Homebrew Cask (GUI, deferred) + +A `cask` for `macMLX.app` is **not** in scope. Casks add notarization +requirements (`brew install --cask` validates `xattr` / Gatekeeper +state on macOS 14+), which we don't have until issue #19 lands. Users +who want GUI distribution via Homebrew can revisit this once the DMG +is signed + notarized. diff --git a/.claude/features/cli-tui.md b/.claude/features/cli-tui.md index c39ee0f..c32e4cd 100644 --- a/.claude/features/cli-tui.md +++ b/.claude/features/cli-tui.md @@ -9,14 +9,20 @@ Shares MacMLXCore with the GUI app — identical inference quality. ## Installation ```bash -# Via Homebrew (v0.2+) -brew install magicnight/mac-mlx/macmlx +# Via Homebrew (shipping post-#20) +brew tap magicnight/mac-mlx +brew install macmlx # Bundled with macMLX.app DMG # /Applications/macMLX.app/Contents/MacOS/macmlx # Symlinked to /usr/local/bin/macmlx during onboarding (optional) ``` +Pipeline lives in `.claude/distribution.md` (Homebrew Tap section): +release CI builds an arm64 tarball, renders `Formula/macmlx.rb`, +and pushes it to `magicnight/homebrew-mac-mlx` when +`HOMEBREW_TAP_TOKEN` is configured. + ## Command Design ``` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e55dac2..b1311b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,25 @@ jobs: GITHUB_REF_NAME: ${{ github.ref_name }} run: ./scripts/package-dmg.sh + - name: Package macmlx CLI tarball + # Builds the Release-mode `macmlx` binary, strips it, and tars + # it as macmlx-${TAG}-arm64.tar.gz for the Homebrew tap (#20). + # Exports MACMLX_CLI_TARBALL + MACMLX_CLI_SHA256 to GITHUB_ENV + # so the next step can render the formula. + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + run: ./scripts/package-cli.sh + + - name: Render Homebrew formula + # Substitutes version/URL/sha256 into Formula/macmlx.rb and + # writes dist/macmlx.rb. Attached to the GitHub Release so the + # tap repo (magicnight/homebrew-mac-mlx) can pull it directly, + # either by hand or by a follow-up automation step. + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + MACMLX_CLI_SHA256: ${{ env.MACMLX_CLI_SHA256 }} + run: ./scripts/render-formula.sh + - name: Sign DMG with Sparkle EdDSA env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -145,6 +164,57 @@ jobs: files: | dist/*.dmg dist/*.sha256 + dist/macmlx-*.tar.gz + dist/macmlx.rb draft: false prerelease: false generate_release_notes: true + + - name: Publish formula to Homebrew tap + # Pushes dist/macmlx.rb to magicnight/homebrew-mac-mlx so + # `brew tap magicnight/mac-mlx && brew install macmlx` Just Works + # (#20). Skipped automatically when the cross-repo PAT isn't + # configured — the rendered formula is still attached to the + # release above, so a maintainer can copy it manually. + if: env.MACMLX_CLI_SHA256 != '' + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [ -z "${HOMEBREW_TAP_TOKEN:-}" ]; then + echo "HOMEBREW_TAP_TOKEN secret not set — skipping tap update." + echo "The rendered formula is attached to this release as macmlx.rb." + echo "To enable auto-publish: create a fine-grained PAT with" + echo "Contents: Read+Write on magicnight/homebrew-mac-mlx and" + echo "store it as HOMEBREW_TAP_TOKEN in this repo's secrets." + exit 0 + fi + if [ ! -f dist/macmlx.rb ]; then + echo "warning: dist/macmlx.rb not found — formula render must have failed." + exit 0 + fi + TAP_DIR="$(mktemp -d)" + git clone --depth 1 \ + "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/magicnight/homebrew-mac-mlx.git" \ + "$TAP_DIR" + mkdir -p "$TAP_DIR/Formula" + cp dist/macmlx.rb "$TAP_DIR/Formula/macmlx.rb" + cd "$TAP_DIR" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/macmlx.rb + if git diff --staged --quiet; then + echo "Formula already up to date." + exit 0 + fi + git commit -m "macmlx ${GITHUB_REF_NAME}" + for attempt in 1 2 3; do + if git push origin HEAD; then + echo "Tap updated on attempt ${attempt}." + exit 0 + fi + git pull --rebase origin HEAD || true + sleep $((attempt * 5)) + done + echo "warning: tap push failed after 3 attempts." + exit 1 diff --git a/Formula/macmlx.rb b/Formula/macmlx.rb new file mode 100644 index 0000000..34707fe --- /dev/null +++ b/Formula/macmlx.rb @@ -0,0 +1,27 @@ +# Formula/macmlx.rb — Homebrew formula template for the `macmlx` CLI. +# Source of truth lives in the macMLX repo; scripts/render-formula.sh +# substitutes version + URL + sha256 on each release and the rendered +# file is published to magicnight/homebrew-mac-mlx. See +# .claude/distribution.md (Homebrew Tap section) for the full pipeline. +class Macmlx < Formula + desc "Native macOS LLM inference CLI for Apple Silicon (powered by MLX)" + homepage "https://github.com/magicnight/mac-mlx" + url "@@URL@@" + version "@@VERSION@@" + sha256 "@@SHA256@@" + license "Apache-2.0" + + # macmlx links against the dynamic Swift stdlib that ships with + # macOS 14+ on Apple Silicon. Hard-fail on anything older or non-arm64 + # rather than producing a runtime crash. + depends_on macos: :sonoma + depends_on arch: :arm64 + + def install + bin.install "macmlx" + end + + test do + assert_match(/^macmlx /, shell_output("#{bin}/macmlx --version")) + end +end diff --git a/scripts/package-cli.sh b/scripts/package-cli.sh new file mode 100755 index 0000000..f88e34c --- /dev/null +++ b/scripts/package-cli.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# scripts/package-cli.sh — Build the `macmlx` CLI in Release configuration +# and package it as a tarball suitable for a Homebrew tap formula +# (issue #20). +# +# Inputs: +# GITHUB_REF_NAME e.g. "v0.3.8"; falls back to current git tag. +# Outputs: +# dist/macmlx-${TAG}-arm64.tar.gz binary tarball +# dist/macmlx-${TAG}-arm64.tar.gz.sha256 +# +# The tarball contains a single top-level `macmlx` executable so the +# Homebrew formula can `bin.install "macmlx"` without unpacking +# directory layers. + +set -euo pipefail + +CLI_NAME="macmlx" +PACKAGE_PATH="macmlx-cli" +TAG="${GITHUB_REF_NAME:-$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0-dev")}" +ARCH="arm64" +TARBALL_BASENAME="${CLI_NAME}-${TAG}-${ARCH}" +TARBALL_NAME="${TARBALL_BASENAME}.tar.gz" + +if [[ "$(uname -m)" != "arm64" ]]; then + echo "error: package-cli.sh must run on an Apple Silicon host (got $(uname -m))." >&2 + exit 1 +fi + +echo "==> Building ${CLI_NAME} ${TAG} (Release, arm64)" + +swift build \ + --package-path "$PACKAGE_PATH" \ + --configuration release \ + --arch arm64 + +BIN_SRC="$(swift build --package-path "$PACKAGE_PATH" --configuration release --arch arm64 --show-bin-path)/${CLI_NAME}" + +if [[ ! -x "$BIN_SRC" ]]; then + echo "error: built binary missing at ${BIN_SRC}" >&2 + exit 1 +fi + +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT +cp "$BIN_SRC" "$STAGE_DIR/${CLI_NAME}" + +# Strip debug symbols. Swift stdlib is dynamically linked from the +# system toolchain on macOS 14+, so we only need the executable itself. +strip -S "$STAGE_DIR/${CLI_NAME}" || true + +mkdir -p dist +rm -f "dist/${TARBALL_NAME}" "dist/${TARBALL_NAME}.sha256" + +tar -czf "dist/${TARBALL_NAME}" -C "$STAGE_DIR" "${CLI_NAME}" + +shasum -a 256 "dist/${TARBALL_NAME}" | tee "dist/${TARBALL_NAME}.sha256" + +# Emit the bare sha for downstream steps (formula rendering). +SHA256="$(awk '{print $1}' "dist/${TARBALL_NAME}.sha256")" +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "MACMLX_CLI_TARBALL=dist/${TARBALL_NAME}" + echo "MACMLX_CLI_SHA256=${SHA256}" + } >> "$GITHUB_ENV" +fi + +echo "==> Packaged dist/${TARBALL_NAME} (sha256 ${SHA256})" diff --git a/scripts/render-formula.sh b/scripts/render-formula.sh new file mode 100755 index 0000000..f2ba1ff --- /dev/null +++ b/scripts/render-formula.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# scripts/render-formula.sh — Render Formula/macmlx.rb for the current +# release into dist/macmlx.rb (issue #20). +# +# Reads: +# GITHUB_REF_NAME e.g. "v0.3.8"; falls back to the latest git tag. +# MACMLX_CLI_SHA256 sha256 of the CLI tarball; falls back to +# reading dist/.sha256 if present. +# Writes: +# dist/macmlx.rb rendered formula, ready to commit to the tap. +# +# Idempotent — safe to re-run. Exits non-zero if the sha256 cannot be +# resolved (better than shipping a formula that points at nothing). + +set -euo pipefail + +REPO_OWNER="${MACMLX_REPO_OWNER:-magicnight}" +REPO_NAME="${MACMLX_REPO_NAME:-mac-mlx}" +ARCH="arm64" +CLI_NAME="macmlx" +TEMPLATE="Formula/macmlx.rb" + +TAG="${GITHUB_REF_NAME:-$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0-dev")}" +VERSION="${TAG#v}" +TARBALL_NAME="${CLI_NAME}-${TAG}-${ARCH}.tar.gz" +URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${TAG}/${TARBALL_NAME}" + +SHA256="${MACMLX_CLI_SHA256:-}" +if [[ -z "$SHA256" && -f "dist/${TARBALL_NAME}.sha256" ]]; then + SHA256="$(awk '{print $1}' "dist/${TARBALL_NAME}.sha256")" +fi + +if [[ -z "$SHA256" ]]; then + echo "error: sha256 unknown — set MACMLX_CLI_SHA256 or run scripts/package-cli.sh first." >&2 + exit 1 +fi + +if [[ ! -f "$TEMPLATE" ]]; then + echo "error: ${TEMPLATE} missing." >&2 + exit 1 +fi + +mkdir -p dist + +# sed -i differs on macOS vs GNU; use a portable form. +sed \ + -e "s|@@VERSION@@|${VERSION}|g" \ + -e "s|@@URL@@|${URL}|g" \ + -e "s|@@SHA256@@|${SHA256}|g" \ + "$TEMPLATE" > "dist/macmlx.rb" + +echo "==> Rendered dist/macmlx.rb (version ${VERSION}, sha256 ${SHA256})"