Skip to content

feat(app): select an LLM and an image model together (hybrid serve) #38

feat(app): select an LLM and an image model together (hybrid serve)

feat(app): select an LLM and an image model together (hybrid serve) #38

Workflow file for this run

name: Release
# Triggered by pushing a tag matching `v*` (e.g. `v0.2.0`). Builds the
# aarch64-apple-darwin bundle (MLX is Apple-Silicon-only — mlx-sys's
# CMakeLists.txt refuses x86_64 outright, so an Intel matrix entry can't
# succeed), signs the `.app.tar.gz` updater bundle with the Ed25519 key
# stored in the `TAURI_SIGNING_PRIVATE_KEY` secret, and uploads the
# artifacts (plus a `latest.json` manifest) to a draft GitHub Release.
# Flip the release from draft to published once the job finishes to
# expose the new version to the in-app updater.
#
# Maintainer setup (one-time): see crates/lumen-app/docs/release.md for
# how to generate the signing keypair + register the secrets on the repo.
on:
push:
tags:
- 'v*'
# Manual dispatch for trial runs without a tag (uploads to a draft
# release named `vTEST-<sha>`). Useful when iterating on this workflow.
workflow_dispatch:
# tauri-action creates a draft GitHub Release + uploads bundle artifacts,
# which requires write access to repo contents. Without this block the
# default token is read-only on repos with restrictive Actions settings,
# and the release-creation step fails with
# "Resource not accessible by integration".
permissions:
contents: write
jobs:
build:
name: build ${{ matrix.target }}
# macos-26 (not macos-14): the MLX Neural-Accelerator (NAX) GEMM path —
# the M5 Pro/Max prefill speedup — is gated at runtime by
# `is_nax_available()`, which expands to `__builtin_available(macOS 26.2)`.
# Compiling that guard needs an SDK that *knows* macOS 26.2, i.e. Xcode 26.x.
# macos-14/macos-15 default to Xcode 16.x and don't reliably carry Xcode 26
# (disk-space constrained), so they fail with "macOS 26.2 is not a valid
# availability version". macos-26 (GA, arm64-native) ships Xcode 26.x by
# default. The kernels are baked into the metallib host-independently, so an
# arm64 runner builds a binary that activates the M5 path at runtime on M5.
runs-on: macos-26
strategy:
fail-fast: false
matrix:
# Apple Silicon only. MLX upstream CMakeLists.txt errors out on
# x86_64 ("Building for x86_64 on macOS is not supported") so an
# Intel matrix entry is fundamentally not possible with the
# mlx-native feature.
target:
- aarch64-apple-darwin
steps:
- uses: actions/checkout@v4
with:
# Tauri's sidecar bundling references the on-disk `lumen-server`
# binary path; the workflow places it relative to the workspace
# root, so the checkout depth must be full (not shallow).
fetch-depth: 0
# ── Pin newest stable Xcode (macOS 26.2 SDK for the NAX availability guard) ──
# macos-26 already defaults to Xcode 26.x, but GitHub rotates that default
# (26.0.1 → 26.2 → 26.3 …) and trims old point releases for disk space.
# `latest-stable` deterministically selects the newest installed 26.x so
# MLX's `__builtin_available(macOS 26.2, *)` guard always finds a ≥26.2 SDK
# and libmlx/metallib build with the NAX kernels.
- name: Select latest stable Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
# ── Metal Toolchain (the `metal` shader compiler) ─────────────
# Xcode 26 ships the Metal compiler as a *separately downloadable*
# component, and macos-26 runners don't pre-install it. mlx-sys's CMake
# build compiles MLX's Metal kernels into mlx.metallib via the `metal`
# tool, so without this the next step dies with:
# "error: cannot execute tool 'metal' due to missing Metal Toolchain;
# use: xcodebuild -downloadComponent MetalToolchain".
# Idempotent; retry once for the occasionally-flaky asset download.
- name: Download Metal Toolchain
run: |
xcodebuild -downloadComponent MetalToolchain \
|| (sleep 20 && xcodebuild -downloadComponent MetalToolchain)
# Fail fast here (not 10 min into the CMake build) if metal is still absent.
xcrun -f metal
xcrun metal --version || true
# ── Toolchains ────────────────────────────────────────────────
- name: Install Rust toolchain (with matrix target)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo registry + git + target/
uses: Swatinem/rust-cache@v2
with:
shared-key: release-${{ matrix.target }}
# Don't cache the lumen-app target/ — Tauri's bundle step
# writes large `.app` directories that bloat the cache key.
workspaces: |
. -> target
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: crates/lumen-app/frontend/package-lock.json
# ── Sibling candle fork (required because Cargo.toml uses path = "../candle") ──
- name: Clone candle fork next to the workspace
run: |
cd ..
if [ ! -d candle ]; then
git clone --depth 1 https://github.com/huggingface/candle.git
fi
ls candle/Cargo.toml
# ── mlx-sys: force-build first so the metallib lands on disk before
# lumen-server's build.rs runs. Without this, a `Swatinem/rust-cache`
# hit can restore mlx-sys's metadata but skip the `out/` directory,
# leaving lumen-server with no metallib to embed.
- name: Pre-build mlx-sys (writes mlx.metallib)
run: |
# mlx-sys's own default features (accelerate + metal) drive the
# CMake build that produces mlx.metallib. No `mlx-native` feature
# exists at the mlx-sys layer — that knob lives one crate up.
cargo clean -p mlx-sys || true
cargo build -p mlx-sys --release --target ${{ matrix.target }}
# Surface the metallib path so the next step (or a failure log) can
# cross-check what build.rs will discover.
echo "--- mlx.metallib search ---"
find target/${{ matrix.target }}/release/build -name mlx.metallib -print 2>/dev/null || true
find target/release/build -name mlx.metallib -print 2>/dev/null || true
# ── Sidecar: build lumen-server, drop it into the bundle path ──
- name: Build lumen-server (sidecar)
run: |
cargo build -p lumen-server --release \
--target ${{ matrix.target }} \
--features mlx-native,qwen3_5_moe
mkdir -p crates/lumen-app/binaries
cp target/${{ matrix.target }}/release/lumen-server \
crates/lumen-app/binaries/lumen-server-${{ matrix.target }}
# ── Frontend install ──────────────────────────────────────────
- name: Install frontend deps
working-directory: crates/lumen-app/frontend
run: npm ci
# ── Tauri build + sign + upload ───────────────────────────────
- name: Build, sign + upload (.app, .dmg, latest.json)
uses: tauri-apps/tauri-action@v0
env:
# Automatically provisioned by Actions — needed for the action to
# create the draft release and upload the bundle + signature.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Apple notary credentials (optional; only set on real releases —
# without these the app is signed by the Tauri key but not notarized,
# so users see a Gatekeeper warning on first launch).
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: crates/lumen-app
# Tag push → use the pushed tag (e.g. v0.2.0). Manual dispatch →
# vTEST-<sha-7> so dry-runs don't collide with real releases.
tagName: ${{ github.event_name == 'push' && github.ref_name || format('vTEST-{0}', github.sha) }}
releaseName: ${{ github.event_name == 'push' && format('Lumen {0}', github.ref_name) || format('Lumen vTEST-{0} (dry-run)', github.sha) }}
releaseDraft: true
prerelease: false
# `--config` injects the sidecar binding so the default
# tauri.conf.json stays clean for `cargo tauri dev`.
args: >-
--target ${{ matrix.target }}
--config '{"bundle":{"externalBin":["binaries/lumen-server"]}}'