Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- uses: ruby/setup-ruby@v1
with:
Expand Down
277 changes: 235 additions & 42 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,60 +1,253 @@
name: Release

# Publishes red-candle when a version tag (e.g. `1.7.2`) is pushed. Produces, for that
# version:
# - the source "ruby" gem (fallback for any platform/ABI below, and for
# GPU/Metal-from-source via force_ruby_platform)
# - red-candle-<v>-x86_64-linux.gem (precompiled, CPU-only, glibc)
# - red-candle-<v>-aarch64-linux.gem (precompiled, CPU-only, glibc; built natively)
# - red-candle-<v>-arm64-darwin.gem (precompiled, Metal + Accelerate; built natively)
# Each fat gem carries one compiled extension per Ruby ABI in RUBY_VERSIONS, so consumers
# install with no Rust toolchain.
#
# Release procedure (the tag, not CI, drives this — a CI-created tag would not re-trigger):
# 1. bump lib/candle/version.rb
# 2. git commit -am "Release vX.Y.Z"
# 3. git tag X.Y.Z && git push origin HEAD --tags
#
# Third-party AND first-party actions are SHA-pinned (supply-chain). Comments show the tag.

on:
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 1.7.0)"
required: true
type: string
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+" # release: 1.8.0
- "[0-9]+.[0-9]+.[0-9]+.*" # prerelease: 1.8.0.pre1, 1.8.0.rc2 (RubyGems dot form)
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+.*"

permissions:
contents: read

env:
# Ruby ABIs compiled into every fat gem. Floor = gemspec required_ruby_version (3.1);
# 3.3 = rx production (3.3.8); 4.0 = current GA. A consumer on an ABI outside this set
# transparently falls back to the source gem (which needs Rust).
RUBY_VERSIONS: "3.1,3.2,3.3,3.4,4.0"

jobs:
release:
runs-on: ubuntu-latest
# ---------------------------------------------------------------------------
# Guard: the pushed tag must match lib/candle/version.rb. Fails fast otherwise.
# ---------------------------------------------------------------------------
prepare:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- id: v
run: |
TAG="${GITHUB_REF_NAME#v}"
FILE_VER="$(ruby -r./lib/candle/version -e 'print Candle::VERSION')"
if [ "$TAG" != "$FILE_VER" ]; then
echo "::error::tag '$TAG' != lib/candle/version.rb ('$FILE_VER'). Bump version.rb before tagging."
exit 1
fi
PRE="$(ruby -rrubygems -e 'print Gem::Version.new(ARGV[0]).prerelease?' "$TAG")"
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing red-candle $TAG (prerelease=$PRE)"

# ---------------------------------------------------------------------------
# Source gem (platform "ruby"). Pushed first and creates the GitHub Release, so
# a tag always yields at least an installable gem even if a native leg flakes.
# ---------------------------------------------------------------------------
source-gem:
needs: prepare
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: ruby/setup-ruby@v1
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
with:
ruby-version: "4.0"
bundler-cache: true
ruby-version: "3.4"
- name: Build source gem
run: gem build candle.gemspec
- name: Push source gem (idempotent)
env:
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
run: |
for gem in red-candle-*.gem; do
if ! out=$(gem push "$gem" 2>&1); then
echo "$out"
echo "$out" | grep -q "Repushing of gem versions is not allowed" \
&& { echo "already published, skipping"; continue; }
exit 1
fi
done
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
PRERELEASE_FLAG=""
[ "${{ needs.prepare.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" red-candle-*.gem --clobber
else
gh release create "$GITHUB_REF_NAME" \
--title "v${{ needs.prepare.outputs.version }}" \
$PRERELEASE_FLAG --generate-notes red-candle-*.gem
fi

- name: Update version
# ---------------------------------------------------------------------------
# Linux precompiled gems. x86_64 on ubuntu-24.04; aarch64 NATIVELY on the
# GitHub arm64 runner (free for public repos) — no QEMU. CPU-only by default
# (candle cargo default features = []); rb-sys-dock has no CUDA toolchain.
# ---------------------------------------------------------------------------
linux-gems:
needs: [prepare, source-gem]
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: x86_64-linux
runs-on: ubuntu-24.04
- platform: aarch64-linux
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
with:
ruby-version: "3.4"
- uses: oxidize-rb/actions/cross-gem@e5f9a49a7812a078584072f6e3f657ad247c8771 # v1.4.4
id: cross-gem
with:
platform: ${{ matrix.platform }}
ruby-versions: ${{ env.RUBY_VERSIONS }}
- name: Push ${{ matrix.platform }} gem (idempotent)
env:
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
run: |
VERSION="${{ inputs.version }}"
sed -i "s/VERSION = \".*\"/VERSION = \"$VERSION\"/" lib/candle/version.rb
echo "Updated version to $VERSION"
cat lib/candle/version.rb
for gem in ${{ steps.cross-gem.outputs.gem-path }}; do
echo "==> $gem"
if ! out=$(gem push "$gem" 2>&1); then
echo "$out"
echo "$out" | grep -q "Repushing of gem versions is not allowed" \
&& { echo "already published, skipping"; continue; }
exit 1
fi
done
- name: Attach to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" ${{ steps.cross-gem.outputs.gem-path }} --clobber || true

- name: Commit and tag
# ---------------------------------------------------------------------------
# arm64-darwin: must be built NATIVELY on Apple Silicon. The oxidize-rb/rb-sys
# cross path is Linux/Docker-only and produces a CPU-only darwin binary (Metal
# frameworks can't link under osxcross). candle 0.9.2 compiles its Metal shaders
# at RUNTIME (no build.rs, no metallib), so building --features metal needs only
# the base CLT SDK — NO Metal Toolchain download, NO full-Xcode select.
# rake-compiler-dock can't make a native-mac fat gem either, so: compile one
# .bundle per Ruby ABI here, then assemble them in darwin-package.
# ---------------------------------------------------------------------------
darwin-build:
needs: prepare
strategy:
fail-fast: false
matrix:
ruby: ["3.1", "3.2", "3.3", "3.4", "4.0"]
runs-on: macos-26
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: oxidize-rb/actions/setup-ruby-and-rust@e5f9a49a7812a078584072f6e3f657ad247c8771 # v1.4.4
with:
ruby-version: ${{ matrix.ruby }}
rustup-toolchain: stable
bundler-cache: true
- name: Compile native extension (Metal + Accelerate auto-enabled on darwin)
run: bundle exec rake compile
- name: Relocate bundle into Ruby-ABI subdir
run: |
VERSION="${{ inputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add lib/candle/version.rb
git commit -m "Release v$VERSION"
git tag "$VERSION"
git push origin HEAD:${{ github.ref_name }} --tags
MINOR="${{ matrix.ruby }}"
test -f lib/candle/candle.bundle
mkdir -p "lib/candle/${MINOR}"
mv lib/candle/candle.bundle "lib/candle/${MINOR}/candle.bundle"
- name: Verify arm64 + Metal/Accelerate linkage (CI runners have no GPU; link check only)
run: |
B="lib/candle/${{ matrix.ruby }}/candle.bundle"
file "$B"
file "$B" | grep -q arm64 \
|| { echo "::error::$B is not arm64 — wrong runner?"; exit 1; }
otool -L "$B"
otool -L "$B" | grep -Eiq '(Metal|Accelerate)\.framework' \
|| { echo "::error::darwin build did not link Metal/Accelerate — CPU-only regression"; exit 1; }
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: darwin-bundle-${{ matrix.ruby }}
path: lib/candle/${{ matrix.ruby }}/candle.bundle
if-no-files-found: error

- name: Build gem
# ---------------------------------------------------------------------------
# Assemble the per-ABI .bundles into ONE arm64-darwin fat gem, push, attach.
# The gemspec's RED_CANDLE_PLATFORM_GEM branch sets platform=arm64-darwin,
# clears spec.extensions (no recompile-on-install), and packs the bundles.
# We publish the GENERIC arm64-darwin platform: RubyGems does not fall back
# across darwin majors, so a generic gem is what serves arm64-darwin-NN users.
# ---------------------------------------------------------------------------
darwin-package:
needs: [prepare, source-gem, darwin-build]
runs-on: macos-26
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
with:
ruby-version: "3.4"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: darwin-bundle-*
path: darwin-bundles
- name: Reassemble per-ABI bundle layout
run: |
for d in darwin-bundles/darwin-bundle-*; do
minor="${d##*darwin-bundle-}"
mkdir -p "lib/candle/${minor}"
mv "${d}/candle.bundle" "lib/candle/${minor}/candle.bundle"
done
echo "Assembled ABIs:"; ls -1 lib/candle/*/candle.bundle
- name: Build arm64-darwin fat gem
env:
RED_CANDLE_PLATFORM_GEM: arm64-darwin
run: gem build candle.gemspec

- name: Push to RubyGems
- name: Verify fat gem carries every ABI bundle
run: |
mkdir -p ~/.gem
echo "---" > ~/.gem/credentials
echo ":rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }}" >> ~/.gem/credentials
chmod 0600 ~/.gem/credentials
gem push red-candle-*.gem

- name: Create GitHub Release
GEM="$(ls red-candle-*-arm64-darwin.gem)"
echo "Inspecting $GEM"
DATA="$(tar -xOf "$GEM" data.tar.gz | tar -tzf -)"
echo "$DATA" | grep -E 'lib/candle/[0-9.]+/candle\.bundle' || true
for v in 3.1 3.2 3.3 3.4 4.0; do
echo "$DATA" | grep -q "lib/candle/${v}/candle.bundle" \
|| { echo "::error::fat gem missing ABI ${v}"; exit 1; }
done
- name: Push arm64-darwin gem (idempotent)
env:
GH_TOKEN: ${{ github.token }}
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
run: |
VERSION="${{ inputs.version }}"
gh release create "$VERSION" \
--title "v$VERSION" \
--generate-notes \
red-candle-*.gem
for gem in red-candle-*-arm64-darwin.gem; do
if ! out=$(gem push "$gem" 2>&1); then
echo "$out"
echo "$out" | grep -q "Repushing of gem versions is not allowed" \
&& { echo "already published, skipping"; continue; }
exit 1
fi
done
- name: Attach to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" red-candle-*-arm64-darwin.gem --clobber || true
15 changes: 14 additions & 1 deletion candle.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ Gem::Specification.new do |spec|
"Gemfile",
"bin/*"
]
spec.extensions = ["ext/candle/extconf.rb"]
# Precompiled platform gems (e.g. arm64-darwin, built natively on a macOS runner)
# carry one compiled extension per Ruby ABI under lib/candle/<major.minor>/ and must
# NOT declare extensions, or RubyGems would try to recompile from Rust source on
# install — defeating the precompiled gem. The linux platform gems are assembled by
# rake-compiler/rb_sys (via oxidize-rb cross-gem), which clears extensions itself; this
# env gate covers the manually-assembled darwin fat gem. Unset => normal source gem.
if (platform_gem = ENV["RED_CANDLE_PLATFORM_GEM"])
spec.platform = platform_gem
spec.extensions = []
spec.files += Dir["lib/candle/*/candle.bundle"] + Dir["lib/candle/*/candle.so"]
else
spec.extensions = ["ext/candle/extconf.rb"]
end

spec.authors = ["Christopher Petersen", "kojix2"]
spec.email = ["chris@petersen.io", "2xijok@gmail.com"]
spec.homepage = "https://github.com/scientist-labs/red-candle"
Expand Down
16 changes: 15 additions & 1 deletion lib/candle.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
require_relative "candle/logger"
require "candle/candle"

# Load the compiled Rust extension. Precompiled (platform) gems install it into a
# Ruby-ABI-versioned subdir (lib/candle/<major.minor>/candle.{so,bundle}) so a single
# fat gem can carry a binary per Ruby version; source/dev builds place it flat at
# lib/candle/candle.{so,bundle}. Try the versioned path first, fall back to the flat
# one. Resolution goes through $LOAD_PATH (`require`, never `require_relative`) because
# RubyGems installs native extensions outside the gem's lib/ dir — see
# spec/require_spec.rb and Issue #75.
begin
RUBY_VERSION =~ /(\d+\.\d+)/
require "candle/#{Regexp.last_match(1)}/candle"
rescue LoadError
require "candle/candle"
end

require_relative "candle/tensor"
require_relative "candle/device_utils"
require_relative "candle/embedding_model_type"
Expand Down
4 changes: 4 additions & 0 deletions spec/require_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@
# This is critical because RubyGems places compiled extensions in the
# extensions directory, not in the gem's lib/ directory.
candle_rb = File.read(File.expand_path("../lib/candle.rb", __dir__))
# Flat fallback path (source/dev builds) must still be present...
expect(candle_rb).to include('require "candle/candle"')
# ...and the Ruby-ABI-versioned path used by precompiled fat gems must be tried
# first (lib/candle/<major.minor>/candle.{so,bundle}).
expect(candle_rb).to match(%r{require "candle/.+/candle"})
expect(candle_rb).not_to include('require_relative "candle/candle"')
end
end
Loading