Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 80 additions & 10 deletions .claude/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>/<name>`
expects `github.com/<user>/homebrew-<name>`. 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.
10 changes: 8 additions & 2 deletions .claude/features/cli-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
27 changes: 27 additions & 0 deletions Formula/macmlx.rb
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions scripts/package-cli.sh
Original file line number Diff line number Diff line change
@@ -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})"
52 changes: 52 additions & 0 deletions scripts/render-formula.sh
Original file line number Diff line number Diff line change
@@ -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/<tarball>.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})"