From 429a28e6dacdd99afc1e98654a986e0cab1258f4 Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 00:38:33 -0400 Subject: [PATCH 1/2] Publish precompiled native gems on tag (x86_64-linux, aarch64-linux, arm64-darwin) red-candle has only ever shipped a source ("ruby" platform) gem, so every consumer compiles the full Candle/Rust extension at `bundle install` time: rb_sys bootstraps rustup via `curl | sh` and builds from scratch on any cache miss. This adds a tag-triggered release pipeline that publishes precompiled fat gems so consumers install with no Rust toolchain. - lib/candle.rb: load the extension from a Ruby-ABI-versioned subdir (lib/candle//candle.{so,bundle}) first, falling back to the flat path, so a fat gem can carry one binary per Ruby version. require_spec updated to assert the versioned-first-with-fallback form. - candle.gemspec: RED_CANDLE_PLATFORM_GEM gate that, for the manually-assembled darwin fat gem, sets the platform, clears spec.extensions (no recompile on install) and packs the per-ABI bundles. - .github/workflows/release.yml: on a version tag, build + push the source gem plus x86_64-linux & aarch64-linux (oxidize-rb cross-gem; aarch64 native on ubuntu-24.04-arm, no QEMU) and a native Metal+Accelerate arm64-darwin fat gem (matrix compile on macos-26 -> merge). ABIs 3.1-4.0. Actions SHA-pinned latest. - build.yml: bump deprecated actions/checkout@v3. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 270 ++++++++++++++++++++++++++++------ candle.gemspec | 15 +- lib/candle.rb | 16 +- spec/require_spec.rb | 4 + 5 files changed, 262 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b1e83a..0521c8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7390344..d5d1717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,60 +1,246 @@ 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--x86_64-linux.gem (precompiled, CPU-only, glibc) +# - red-candle--aarch64-linux.gem (precompiled, CPU-only, glibc; built natively) +# - red-candle--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]+" + - "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 }} + 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 + echo "version=$TAG" >> "$GITHUB_OUTPUT" + echo "Releasing red-candle $TAG" + + # --------------------------------------------------------------------------- + # 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: | + 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 }}" \ + --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 diff --git a/candle.gemspec b/candle.gemspec index 3a5931f..34621e3 100644 --- a/candle.gemspec +++ b/candle.gemspec @@ -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// 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" diff --git a/lib/candle.rb b/lib/candle.rb index 3c25e3b..56f7ab3 100644 --- a/lib/candle.rb +++ b/lib/candle.rb @@ -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//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" diff --git a/spec/require_spec.rb b/spec/require_spec.rb index b835f49..b32c0b6 100644 --- a/spec/require_spec.rb +++ b/spec/require_spec.rb @@ -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//candle.{so,bundle}). + expect(candle_rb).to match(%r{require "candle/.+/candle"}) expect(candle_rb).not_to include('require_relative "candle/candle"') end end From cd676ad6c7edd879858bf7a211a247f03ddb1130 Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 00:52:19 -0400 Subject: [PATCH 2/2] release: trigger on prerelease tags + mark GH release as pre-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow X.Y.Z.preN / .rcN tags (RubyGems dot form) to drive the pipeline so the full tag -> rubygems.org flow can be exercised with e.g. 1.8.0.pre1 — which normal requirements (rx's `~> 1`, plain `gem install`) ignore. The prepare job now derives a prerelease flag (Gem::Version#prerelease?) and the GitHub Release is marked --prerelease accordingly. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5d1717..9b60b4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,10 @@ name: Release on: push: tags: - - "[0-9]+.[0-9]+.[0-9]+" + - "[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 @@ -40,6 +42,7 @@ jobs: 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 @@ -50,8 +53,10 @@ jobs: 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 "Releasing red-candle $TAG" + echo "prerelease=$PRE" >> "$GITHUB_OUTPUT" + echo "Releasing red-candle $TAG (prerelease=$PRE)" # --------------------------------------------------------------------------- # Source gem (platform "ruby"). Pushed first and creates the GitHub Release, so @@ -85,12 +90,14 @@ jobs: 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 }}" \ - --generate-notes red-candle-*.gem + $PRERELEASE_FLAG --generate-notes red-candle-*.gem fi # ---------------------------------------------------------------------------