Skip to content

Commit 0848c77

Browse files
juntaoclaude
andcommitted
Add release workflow with bundled native libraries
On every GitHub Release, build both the transcribe CLI and transcribe-server for all three platforms and upload zips as release assets: transcribe-linux-x86_64.zip — tch-backend, libtorch/lib/ bundled transcribe-linux-aarch64.zip — tch-backend, libtorch/lib/ bundled transcribe-macos-aarch64.zip — mlx backend, lib/*.dylib bundled build.rs: add RELEASE_RPATH_ORIGIN=1 support - tch: emit $ORIGIN/libtorch/lib rpath instead of absolute LIBTORCH path so the binaries find libtorch in the adjacent folder when deployed - mlx: suppress rpath emission; dylibbundler rewrites all load commands to @loader_path/lib after the build release.yml: - generate-vocab job: downloads tokenizer.model from HuggingFace (~800 KB) and runs tools/extract_vocab.py; vocab.json shared as artifact across all build jobs - build matrix: ubuntu-latest (x86_64), ubuntu-24.04-arm (aarch64), macos-15 (Apple Silicon) - Linux: strips binaries; copies libtorch/lib/ (runtime only, no headers or cmake files) - macOS: uses dylibbundler to collect libmlxc + libmlx + transitive deps into lib/, fixes install names to @loader_path/lib Signed-off-by: Michael Yuan <michael@secondstate.io> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a0d3ad6 commit 0848c77

File tree

2 files changed

+213
-6
lines changed

2 files changed

+213
-6
lines changed

.github/workflows/release.yml

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
name: Release
2+
3+
# Triggered when a GitHub Release is published.
4+
# Builds CLI + API-server binaries for all three platforms, bundles each with
5+
# its native runtime library (libtorch on Linux, mlx-c + MLX on macOS), and
6+
# uploads the resulting zip files as release assets.
7+
8+
on:
9+
release:
10+
types: [published]
11+
12+
env:
13+
CARGO_TERM_COLOR: always
14+
LIBTORCH_BYPASS_VERSION_CHECK: "1"
15+
16+
jobs:
17+
18+
# ─────────────────────────────────────────────────────────────────────────────
19+
# Download only the SentencePiece tokenizer file from HuggingFace (~800 KB)
20+
# and convert it to vocab.json once; all build jobs share the artifact.
21+
# ─────────────────────────────────────────────────────────────────────────────
22+
generate-vocab:
23+
name: Generate vocab.json
24+
runs-on: ubuntu-latest
25+
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Download tokenizer from HuggingFace
30+
env:
31+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
32+
run: |
33+
pip install -q huggingface_hub sentencepiece
34+
huggingface-cli download CohereLabs/cohere-transcribe-03-2026 \
35+
--include "tokenizer.model" \
36+
--local-dir models/cohere-transcribe-03-2026 \
37+
--token "$HF_TOKEN"
38+
python tools/extract_vocab.py --model_dir models/cohere-transcribe-03-2026
39+
40+
- name: Upload vocab artifact
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: vocab
44+
path: models/cohere-transcribe-03-2026/vocab.json
45+
46+
# ─────────────────────────────────────────────────────────────────────────────
47+
# Build both binaries (transcribe CLI + transcribe-server) for each platform,
48+
# bundle with native libraries, and upload as a release asset zip.
49+
# ─────────────────────────────────────────────────────────────────────────────
50+
build:
51+
name: Build (${{ matrix.name }})
52+
needs: generate-vocab
53+
permissions:
54+
contents: write
55+
56+
strategy:
57+
fail-fast: false
58+
matrix:
59+
include:
60+
# ── Linux x86_64 — CPU libtorch ───────────────────────────────────
61+
- os: ubuntu-latest
62+
name: Linux x86_64
63+
backend: tch
64+
asset-name: transcribe-linux-x86_64
65+
libtorch-url: >-
66+
https://github.com/second-state/libtorch-releases/releases/download/v2.7.1/libtorch-cxx11-abi-x86_64-2.7.1.tar.gz
67+
68+
# ── Linux aarch64 — CPU libtorch (SVE256, Graviton3 / Altra) ─────
69+
- os: ubuntu-24.04-arm
70+
name: Linux aarch64
71+
backend: tch
72+
asset-name: transcribe-linux-aarch64
73+
libtorch-url: >-
74+
https://github.com/second-state/libtorch-releases/releases/download/v2.7.1/libtorch-cxx11-abi-aarch64-2.7.1.tar.gz
75+
76+
# ── macOS Apple Silicon — MLX backend ────────────────────────────
77+
- os: macos-15
78+
name: macOS Apple Silicon
79+
backend: mlx
80+
asset-name: transcribe-macos-aarch64
81+
82+
runs-on: ${{ matrix.os }}
83+
84+
steps:
85+
- uses: actions/checkout@v4
86+
87+
- name: Install Rust stable
88+
uses: dtolnay/rust-toolchain@stable
89+
90+
# ── libtorch (Linux) ──────────────────────────────────────────────────
91+
- name: Download and extract libtorch
92+
if: matrix.backend == 'tch'
93+
run: |
94+
curl -fsSLo libtorch.tar.gz "${{ matrix.libtorch-url }}"
95+
tar xzf libtorch.tar.gz # → ./libtorch/
96+
rm libtorch.tar.gz
97+
98+
# ── mlx-c (macOS) ─────────────────────────────────────────────────────
99+
- name: Install MLX and dylibbundler via Homebrew
100+
if: matrix.backend == 'mlx'
101+
run: brew install mlx dylibbundler
102+
103+
- name: Build mlx-c from source
104+
if: matrix.backend == 'mlx'
105+
run: |
106+
git clone --depth 1 https://github.com/ml-explore/mlx-c.git /tmp/mlx-c
107+
cmake -S /tmp/mlx-c -B /tmp/mlx-c/build \
108+
-DCMAKE_BUILD_TYPE=Release \
109+
-DCMAKE_PREFIX_PATH="$(brew --prefix mlx)" \
110+
-DCMAKE_INSTALL_PREFIX=/opt/mlx
111+
cmake --build /tmp/mlx-c/build --parallel "$(sysctl -n hw.logicalcpu)"
112+
sudo cmake --install /tmp/mlx-c/build
113+
114+
# ── Build ─────────────────────────────────────────────────────────────
115+
- name: Build release binaries (tch-backend)
116+
if: matrix.backend == 'tch'
117+
env:
118+
LIBTORCH: ${{ github.workspace }}/libtorch
119+
# RELEASE_RPATH_ORIGIN=1 makes build.rs emit $ORIGIN/libtorch/lib
120+
# instead of the absolute workspace path, so the binaries find
121+
# libtorch in the adjacent libtorch/ folder when deployed.
122+
RELEASE_RPATH_ORIGIN: "1"
123+
run: cargo build --release
124+
125+
- name: Build release binaries (mlx)
126+
if: matrix.backend == 'mlx'
127+
env:
128+
MLX_DIR: /opt/mlx
129+
# RELEASE_RPATH_ORIGIN=1 suppresses the absolute-path rpath from
130+
# build.rs; dylibbundler (below) rewrites all dylib load commands to
131+
# @loader_path/lib and adds that rpath to the binaries.
132+
RELEASE_RPATH_ORIGIN: "1"
133+
run: MLX_DIR=/opt/mlx cargo build --release --no-default-features --features mlx
134+
135+
# ── Download shared vocab ─────────────────────────────────────────────
136+
- name: Download vocab.json
137+
uses: actions/download-artifact@v4
138+
with:
139+
name: vocab
140+
141+
# ── Package (Linux / tch) ─────────────────────────────────────────────
142+
- name: Package release (Linux)
143+
if: matrix.backend == 'tch'
144+
run: |
145+
DIR=${{ matrix.asset-name }}
146+
mkdir -p "$DIR/libtorch/lib"
147+
148+
# Binaries
149+
cp target/release/transcribe "$DIR/transcribe"
150+
cp target/release/transcribe-server "$DIR/transcribe-server"
151+
cp vocab.json "$DIR/vocab.json"
152+
153+
# Runtime libraries only (skip headers and cmake files to save space)
154+
cp -r libtorch/lib/. "$DIR/libtorch/lib/"
155+
156+
# Strip debug info from our binaries
157+
strip "$DIR/transcribe" "$DIR/transcribe-server"
158+
159+
zip -r "$DIR.zip" "$DIR"
160+
echo "=== Package contents ==="
161+
zipinfo "$DIR.zip" | head -30
162+
163+
# ── Package (macOS / mlx) ─────────────────────────────────────────────
164+
- name: Package release (macOS)
165+
if: matrix.backend == 'mlx'
166+
run: |
167+
DIR=${{ matrix.asset-name }}
168+
mkdir -p "$DIR/lib"
169+
170+
# Binaries
171+
cp target/release/transcribe "$DIR/transcribe"
172+
cp target/release/transcribe-server "$DIR/transcribe-server"
173+
cp vocab.json "$DIR/vocab.json"
174+
175+
# Bundle all non-system dylibs (libmlxc + libmlx + transitive deps)
176+
# into lib/, rewrite load commands to @loader_path/lib, and add that
177+
# rpath entry to both binaries.
178+
dylibbundler \
179+
--overwrite-dir \
180+
--bundle-deps \
181+
--fix-file "$DIR/transcribe" \
182+
--fix-file "$DIR/transcribe-server" \
183+
--dest-dir "$DIR/lib" \
184+
--install-path @loader_path/lib
185+
186+
zip -r "$DIR.zip" "$DIR"
187+
echo "=== Package contents ==="
188+
zipinfo "$DIR.zip" | head -30
189+
190+
# ── Upload to GitHub Release ──────────────────────────────────────────
191+
- name: Upload release asset
192+
env:
193+
GH_TOKEN: ${{ github.token }}
194+
run: gh release upload "${{ github.event.release.tag_name }}" "${{ matrix.asset-name }}.zip"

build.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,21 @@ fn main() {
1515
/// build time so the binary is self-contained with respect to library lookup.
1616
fn build_tch() {
1717
let libtorch = std::env::var("LIBTORCH").unwrap_or_else(|_| "/opt/libtorch".to_string());
18-
let lib_dir = format!("{}/lib", libtorch);
1918

20-
// Embed as RPATH (Linux ELF RUNPATH) — the dynamic linker uses this at
21-
// runtime without any environment variable.
22-
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir);
19+
// For release packages the binary must find libtorch relative to itself
20+
// ($ORIGIN = directory containing the ELF binary), so the user can unzip
21+
// and run without any environment variables.
22+
// For dev/CI builds we embed the absolute LIBTORCH path instead.
23+
let rpath = if std::env::var("RELEASE_RPATH_ORIGIN").is_ok() {
24+
"$ORIGIN/libtorch/lib".to_string()
25+
} else {
26+
format!("{}/lib", libtorch)
27+
};
28+
29+
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", rpath);
2330

2431
println!("cargo:rerun-if-env-changed=LIBTORCH");
32+
println!("cargo:rerun-if-env-changed=RELEASE_RPATH_ORIGIN");
2533
println!("cargo:rerun-if-changed=build.rs");
2634
}
2735

@@ -50,8 +58,12 @@ fn build_mlx() {
5058
// mlx-c: C wrapper around the MLX C++ library
5159
println!("cargo:rustc-link-lib=dylib=mlxc");
5260

53-
// Embed RPATH so the binary finds libmlxc.dylib at runtime
54-
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir);
61+
// Embed RPATH so the binary finds libmlxc.dylib at runtime.
62+
// For release packages we skip this — dylibbundler rewrites all dylib
63+
// load commands to @loader_path/lib after the build, so no rpath is needed.
64+
if std::env::var("RELEASE_RPATH_ORIGIN").is_err() {
65+
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir);
66+
}
5567

5668
// Apple system frameworks required by MLX
5769
println!("cargo:rustc-link-lib=framework=Metal");
@@ -64,5 +76,6 @@ fn build_mlx() {
6476
println!("cargo:rustc-link-lib=c++");
6577

6678
println!("cargo:rerun-if-env-changed=MLX_DIR");
79+
println!("cargo:rerun-if-env-changed=RELEASE_RPATH_ORIGIN");
6780
println!("cargo:rerun-if-changed=build.rs");
6881
}

0 commit comments

Comments
 (0)