Skip to content

ci(release): add static ONNX Runtime build for macOS x86_64 #45

ci(release): add static ONNX Runtime build for macOS x86_64

ci(release): add static ONNX Runtime build for macOS x86_64 #45

Workflow file for this run

name: Release
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+*' # Matches semver tags like 0.1.0, 1.0.0, etc.
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (semver format, e.g., 0.1.0)'
required: true
type: string
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
create-release:
name: Create Release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ steps.get_version.outputs.VERSION }}
changelog: ${{ steps.changelog.outputs.CHANGELOG }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history for changelog generation
- name: Get version from tag
id: get_version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.tag }}"
else
VERSION="${GITHUB_REF#refs/tags/}"
fi
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "VERSION_NO_V=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Validate semver format
run: |
VERSION="${{ steps.get_version.outputs.VERSION }}"
# POSIX-compatible regex check for semver
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*)
# Basic semver format matched
;;
*)
echo "Error: Tag '$VERSION' is not a valid semver format"
echo "Expected format: X.Y.Z, X.Y.Z-prerelease, or X.Y.Z+build"
exit 1
;;
esac
- name: Generate changelog
id: changelog
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
CHANGELOG="Initial release"
else
# Generate changelog from commits since last tag
CHANGELOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s" --no-merges | head -20)
if [ -z "$CHANGELOG" ]; then
CHANGELOG="- No changes since last release"
fi
fi
# Escape for GitHub output (POSIX compatible)
CHANGELOG=$(printf '%s\n' "$CHANGELOG" | sed 's/%/%25/g; s/\r/%0D/g' | awk '{printf "%s%0A", $0}' | sed 's/%0A$//')
echo "CHANGELOG=$CHANGELOG" >> $GITHUB_OUTPUT
- name: Check if release exists
id: check_release
run: |
VERSION="${{ steps.get_version.outputs.VERSION }}"
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
id: create_release
if: steps.check_release.outputs.exists == 'false'
run: |
VERSION="${{ steps.get_version.outputs.version }}"
IS_PRERELEASE="false"
# Check if this is a prerelease (contains - or is 0.x.x)
case "$VERSION" in
*-*|0.*)
IS_PRERELEASE="true"
;;
esac
# Create the release as draft
if [ "$IS_PRERELEASE" = "true" ]; then
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.CHANGELOG }}" \
--draft \
--prerelease
else
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes "${{ steps.changelog.outputs.CHANGELOG }}" \
--draft
fi
# Get release info
RELEASE_INFO=$(gh release view "$VERSION" --json id,uploadUrl)
RELEASE_ID=$(echo "$RELEASE_INFO" | jq -r '.id')
UPLOAD_URL=$(echo "$RELEASE_INFO" | jq -r '.uploadUrl')
echo "id=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "upload_url=${UPLOAD_URL}" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build:
name: Build ${{ matrix.target }}
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
archive: tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-22.04-arm
archive: tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
- target: aarch64-pc-windows-msvc
os: windows-11-arm
archive: zip
- target: x86_64-apple-darwin
os: macos-15-intel
archive: tar.gz
- target: aarch64-apple-darwin
os: macos-15
archive: tar.gz
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@1.95.0
with:
targets: ${{ matrix.target }}
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ endsWith(matrix.target, '-unknown-linux-musl') && 'docker-alpine' || runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ endsWith(matrix.target, '-unknown-linux-musl') && 'docker-alpine' || runner.os }}-${{ matrix.target }}-cargo-
${{ endsWith(matrix.target, '-unknown-linux-musl') && 'docker-alpine' || runner.os }}-cargo-
# Cache the static ONNX Runtime build for musl targets so we don't rebuild
# (45–60 min) on every CI run. Cache full source+build tree because
# ort-sys's full static-link branch needs build/_deps/ subtrees for
# re2/abseil/protobuf/etc.
# Cache the static ONNX Runtime build for targets where pyke ships no
# prebuilts (musl, x86_64-apple-darwin). Cache full source+build tree
# because ort-sys's full static-link branch needs build/_deps/ subtrees
# for re2/abseil/protobuf/etc.
- name: Cache ONNX Runtime static build
if: endsWith(matrix.target, '-unknown-linux-musl') || matrix.target == 'x86_64-apple-darwin'
uses: actions/cache@v4
with:
path: onnxruntime-src
key: onnxruntime-1.24.2-${{ matrix.target }}-v6
- name: Build binary
shell: sh
run: |
if [ "${{ runner.os }}" = "Linux" ]; then
# Linux musl: build ONNX Runtime statically from source inside Alpine,
# then build octomind with fastembed enabled, linking ort-sys against
# the local static build via ORT_LIB_LOCATION. Pyke ships no musl
# prebuilts, so this is the only supported path. ORT 1.24.2 matches
# ort-sys 2.0.0-rc.12 / fastembed's api-24 feature.
#
# Pre-create the cache dir on the host so Docker volume-mount
# inherits writable permissions.
mkdir -p onnxruntime-src
docker run --rm \
-v "$(pwd):/workspace" \
-w /workspace \
rust:1.95.0-alpine3.22 \
sh -c '
set -eu
# No protobuf-dev: it pulls in abseil-cpp-dev transitively, which
# makes ORT'\''s CMake skip its bundled abseil and (because re2
# depends on bundled absl targets) drop re2 from the build entirely.
# ort-sys 2.0.0-rc.12 hardcodes static links to ~25 absl_*, re2,
# protobuf libs at fixed _deps/ paths, so all three must be built
# by ORT, not the system. Without the system packages ORT
# FetchContent'\''s and compiles all three from source.
apk add --no-cache \
git perl bash musl-dev openssl-dev openssl-libs-static \
pkgconfig gcc g++ \
cmake make linux-headers python3 py3-pip patch
# Provide stub execinfo.h — musl lacks it and Alpine 3.17+ removed
# libexecinfo. ORT'\''s stacktrace.cc unconditionally includes it on
# non-Android POSIX. The stub provides no-op backtrace functions.
# Written to /tmp since /usr/include may not be writable.
mkdir -p /tmp/include
cat > /tmp/include/execinfo.h << "EOF"
#ifndef _EXECINFO_H
#define _EXECINFO_H
#include <stddef.h>
static inline int backtrace(void **buffer, int size) { (void)buffer; (void)size; return 0; }
static inline char **backtrace_symbols(void *const *buffer, int size) { (void)buffer; (void)size; return NULL; }
static inline void backtrace_symbols_fd(void *const *buffer, int size, int fd) { (void)buffer; (void)size; (void)fd; }
#endif
EOF
export C_INCLUDE_PATH=/tmp/include
export CPLUS_INCLUDE_PATH=/tmp/include
rustup target add ${{ matrix.target }}
ORT_SRC=/workspace/onnxruntime-src
ORT_LIB_LOCATION="$ORT_SRC/build/Release"
# libre2.a is the late marker — re2 is built last in the bundled
# dep graph (after abseil + protobuf), so its presence proves the
# full static tree is in place. A partial cache that only has
# libonnxruntime_common.a but no libre2.a triggers a clean rebuild.
if [ ! -f "$ORT_LIB_LOCATION/_deps/re2-build/libre2.a" ]; then
echo "==> Building ONNX Runtime 1.24.2 from source (static, musl)"
rm -rf "$ORT_SRC"
git clone --single-branch --branch v1.24.2 --recursive \
https://github.com/microsoft/onnxruntime.git "$ORT_SRC"
cd "$ORT_SRC"
./build.sh \
--config Release \
--build_dir build \
--parallel \
--skip_tests \
--skip_submodule_sync \
--allow_running_as_root \
--compile_no_warning_as_error \
--no_kleidiai \
--no_sve \
--cmake_extra_defines \
CMAKE_POSITION_INDEPENDENT_CODE=ON \
onnxruntime_BUILD_UNIT_TESTS=OFF \
onnxruntime_BUILD_SHARED_LIB=OFF
# ORT'\''s static build (.a archives, --skip_tests) never link an
# executable, so target_link_libraries deps like re2::re2 don'\''t
# trigger compilation — re2 is FetchContent-populated but its
# CMake target stays unbuilt. ort-sys 2.0.0-rc.12 hardcodes a
# static link to it. Explicitly drive the re2 target inside ORT'\''s
# configured build tree (uses bundled abseil from the same tree).
cmake --build "$ORT_LIB_LOCATION" --target re2 -j
cd /workspace
else
echo "==> Using cached ONNX Runtime static build"
fi
# ort-sys static_config #1 with empty profile detection.
# ORT_LIB_LOCATION = build/Release (where libonnxruntime_common.a lives directly).
# Profile detection iterates build/Release/{Release,RelWithDebInfo,...},
# none exist → profile="" → transform_dep adds no suffix.
# lib_dir = base ✓, external_lib_dir = base/_deps = build/Release/_deps
# which is where cmake actually places onnx-build/, re2-build/, abseil_cpp-build/, etc.
#
# DO NOT use ORT_LIB_LOCATION="$ORT_SRC/build": that triggers profile=Release
# detection, which makes transform_dep append /Release to every _deps path,
# so search paths land at build/_deps/onnx-build/Release (non-existent) and
# linking fails with "could not find native static library 'onnx'".
export ORT_LIB_LOCATION="$ORT_LIB_LOCATION"
# GCC 15 (Alpine 3.23) introduced __cpu_features2 in libgcc.
# ORT'\''s cpuid_info.cc.o references it; musl static link needs
# libgcc.a explicitly or the symbol is undefined at link time.
export RUSTFLAGS="-C link-arg=-lgcc"
cargo build --release --target ${{ matrix.target }}
'
elif [ "${{ runner.os }}" = "Windows" ]; then
# Windows: Pyke ships prebuilts for MSVC targets.
# Pyke's prebuilt ORT static lib is compiled with /MD (dynamic CRT).
# Rust on MSVC defaults to /MT (static CRT) → __imp_* unresolved
# symbol errors at link time. Switch to dynamic CRT to match.
# vcruntime140.dll is present on all Windows hosts.
export RUSTFLAGS="-C target-feature=-crt-static"
cargo build --release --target ${{ matrix.target }}
elif [ "${{ matrix.target }}" = "x86_64-apple-darwin" ]; then
# macOS x86_64: pyke 2.0.0-rc.12 ships no x86_64-apple-darwin
# prebuilt (xcframework linking unsupported on this target). Build
# ONNX Runtime 1.24.2 from source statically and link via
# ORT_LIB_LOCATION, same approach as Linux musl.
ORT_SRC="$(pwd)/onnxruntime-src"
ORT_LIB_LOCATION="$ORT_SRC/build/Release"
if [ ! -f "$ORT_LIB_LOCATION/_deps/re2-build/libre2.a" ]; then
echo "==> Building ONNX Runtime 1.24.2 from source (static, macOS x86_64)"
rm -rf "$ORT_SRC"
git clone --single-branch --branch v1.24.2 --recursive \
https://github.com/microsoft/onnxruntime.git "$ORT_SRC"
(cd "$ORT_SRC" && ./build.sh \
--config Release \
--build_dir build \
--parallel \
--skip_tests \
--skip_submodule_sync \
--compile_no_warning_as_error \
--cmake_extra_defines \
CMAKE_POSITION_INDEPENDENT_CODE=ON \
CMAKE_OSX_ARCHITECTURES=x86_64 \
CMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
onnxruntime_BUILD_UNIT_TESTS=OFF \
onnxruntime_BUILD_SHARED_LIB=OFF)
# Static archive build never links an executable, so re2's
# CMake target stays unbuilt while ort-sys hardcodes a static
# link to libre2.a. Force-build it.
cmake --build "$ORT_LIB_LOCATION" --target re2 -j
else
echo "==> Using cached ONNX Runtime static build"
fi
export ORT_LIB_LOCATION="$ORT_LIB_LOCATION"
cargo build --release --target ${{ matrix.target }}
else
# Native build for other macOS targets (aarch64) — Pyke ships prebuilts.
cargo build --release --target ${{ matrix.target }}
fi
- name: Create archive directory
shell: sh
run: mkdir -p dist
- name: Create archive (Unix)
if: matrix.archive == 'tar.gz'
shell: sh
run: |
cd target/${{ matrix.target }}/release
case "${{ matrix.target }}" in
*windows*)
tar czf ../../../dist/octomind-${{ needs.create-release.outputs.version }}-${{ matrix.target }}.tar.gz octomind.exe
;;
*)
tar czf ../../../dist/octomind-${{ needs.create-release.outputs.version }}-${{ matrix.target }}.tar.gz octomind
;;
esac
- name: Create archive (Windows)
if: matrix.archive == 'zip'
shell: sh
run: |
cd target/${{ matrix.target }}/release
7z a ../../../dist/octomind-${{ needs.create-release.outputs.version }}-${{ matrix.target }}.zip octomind.exe
- name: Upload release asset
shell: sh
run: |
gh release upload "${{ needs.create-release.outputs.version }}" \
"./dist/octomind-${{ needs.create-release.outputs.version }}-${{ matrix.target }}.${{ matrix.archive }}" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crate:
name: Publish to Crates.io
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@1.95.0
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-publish-
${{ runner.os }}-cargo-
- name: Validate version matches tag
run: |
VERSION="${{ needs.create-release.outputs.version }}"
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
if [ "$VERSION" != "$CARGO_VERSION" ]; then
echo "❌ Version mismatch!"
echo "Git tag: $VERSION"
echo "Cargo.toml: $CARGO_VERSION"
echo "Please update Cargo.toml version to match the git tag"
exit 1
fi
echo "✅ Version validation passed: $VERSION"
- name: Check if version already published
id: check_published
run: |
VERSION="${{ needs.create-release.outputs.version }}"
# Check if this version already exists on crates.io
if cargo search octomind --limit 1 | grep -q "octomind = \"$VERSION\""; then
echo "already_published=true" >> $GITHUB_OUTPUT
echo "⚠️ Version $VERSION already published to crates.io"
else
echo "already_published=false" >> $GITHUB_OUTPUT
echo "✅ Version $VERSION not yet published"
fi
- name: Dry run publish
if: steps.check_published.outputs.already_published == 'false'
run: |
echo "🔍 Running dry-run publish to validate package..."
cargo publish --dry-run --no-default-features
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Publish to crates.io
if: steps.check_published.outputs.already_published == 'false'
run: |
echo "🚀 Publishing to crates.io..."
cargo publish --no-default-features
echo "✅ Successfully published to crates.io!"
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Skip publishing
if: steps.check_published.outputs.already_published == 'true'
run: |
echo "⏭️ Skipping crates.io publish - version already exists"
finalize-release:
name: Finalize Release
needs: [create-release, build, publish-crate]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Publish release
run: |
VERSION="${{ needs.create-release.outputs.version }}"
# Update release notes with download links
cat > release_notes.md << EOF
## 🚀 What's Changed
${{ needs.create-release.outputs.changelog }}
## 📦 Installation
### Quick Install Script (Universal)
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/main/install.sh | sh
\`\`\`
**Works on:** Linux, macOS, Windows (Git Bash/WSL/MSYS2), and any Unix-like system
### Manual Download
| Platform | Architecture | Download |
|----------|--------------|----------|
| Linux | x86_64 (static) | [octomind-${VERSION}-x86_64-unknown-linux-musl.tar.gz](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-x86_64-unknown-linux-musl.tar.gz) |
| Linux | ARM64 (static) | [octomind-${VERSION}-aarch64-unknown-linux-musl.tar.gz](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-aarch64-unknown-linux-musl.tar.gz) |
| Windows | x86_64 | [octomind-${VERSION}-x86_64-pc-windows-msvc.zip](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-x86_64-pc-windows-msvc.zip) |
| Windows | ARM64 | [octomind-${VERSION}-aarch64-pc-windows-msvc.zip](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-aarch64-pc-windows-msvc.zip) |
| macOS | x86_64 | [octomind-${VERSION}-x86_64-apple-darwin.tar.gz](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-x86_64-apple-darwin.tar.gz) |
| macOS | ARM64 | [octomind-${VERSION}-aarch64-apple-darwin.tar.gz](https://github.com/${{ github.repository }}/releases/download/${VERSION}/octomind-${VERSION}-aarch64-apple-darwin.tar.gz) |
### Using Cargo (from crates.io)
\`\`\`bash
cargo install octomind
\`\`\`
### Using Cargo (from Git)
\`\`\`bash
cargo install --git https://github.com/${{ github.repository }}
\`\`\`
### Verify Installation
\`\`\`bash
octomind --version
\`\`\`
EOF
# Publish the release (remove draft status)
gh release edit "$VERSION" --draft=false --notes-file release_notes.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-homebrew:
name: Notify Homebrew Tap
needs: [create-release, finalize-release]
runs-on: ubuntu-latest
steps:
- name: Dispatch update to homebrew-tap
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.TAP_GITHUB_TOKEN }}" \
https://api.github.com/repos/muvon/homebrew-tap/dispatches \
-d "{\"event_type\":\"octomind-release\",\"client_payload\":{\"version\":\"${{ needs.create-release.outputs.version }}\"}}"
docker:
name: Build and Push Docker Images
needs: [create-release, build, publish-crate]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase repository name
id: repo
run: echo "repository=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ steps.repo.outputs.repository }}:latest
ghcr.io/${{ steps.repo.outputs.repository }}:${{ needs.create-release.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max