From d2c726eaa8dcde0614ca5502312e4d49e0cc657a Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 14:31:18 -0400 Subject: [PATCH 1/5] Adopt scientist-labs/rust-gem-release@v0 for releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate spellkit to the shared rust-gem-release reusable workflow so it publishes precompiled fat gems (arm64-darwin + x86_64-linux + aarch64-linux) alongside the source gem, instead of source-only. - .github/workflows/release.yml: thin caller pinned @v0 (tag-push + manual dispatch). publish defaults to DRY-RUN on workflow_dispatch and is gated on tag-push; rubygems-api-key maps to RUBYGEMS_API_KEY (empty token = no-op). - .github/workflows/build.yml: PR-CI caller @v0 (rake spec); replaces ci.yml. - .github/workflows/ci.yml: removed (superseded by build.yml). - lib/spellkit.rb: ABI require-shim — load versioned subdir lib/spellkit//spellkit first, fall back to the flat path, so precompiled fat gems actually load. - spellkit.gemspec: ENV["RUST_GEM_PLATFORM"] platform branch so the platform gems clear extensions and ship the compiled bundles/.so. Proven template: red-candle 1.8.0. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 14 ++++++++++ .github/workflows/ci.yml | 26 ------------------ .github/workflows/release.yml | 51 +++++++++++++++++++++++++++++++++++ lib/spellkit.rb | 11 ++++++-- spellkit.gemspec | 15 ++++++++++- 5 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..af33ba4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,14 @@ +name: build + +# Thin PR-CI caller for the shared scientist-labs/rust-gem-release build workflow +# (pinned @v0). Supersedes the hand-rolled ci.yml: it compiles the native extension +# and runs the spec suite across the supported Ruby versions, with cache + setup-ruby +# centralized in the reusable workflow. + +on: pull_request + +jobs: + build: + uses: scientist-labs/rust-gem-release/.github/workflows/build.yml@v0 + with: + test-command: bundle exec rake spec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b4a0645..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: build -on: pull_request -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - tmp - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ruby - bundler-cache: true - - - name: Compile native extension - run: bundle exec rake compile - - - name: Run specs - run: | - bundle exec rake spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65fbd82 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +# Thin caller for the shared scientist-labs/rust-gem-release reusable workflow. +# It owns only what an `on: workflow_call` workflow cannot: the tag trigger, the +# contents:write token grant, and spellkit's per-gem inputs/secrets. Every build +# leg (source + arm64-darwin + x86_64-linux + aarch64-linux), SHA pin, and gotcha +# lives once in the shared workflow (pinned @v0 = moving major). See red-candle's +# release.yml for the proven template. +# +# Release procedure (the tag drives it; a CI-created tag would not re-trigger): +# 1. bump lib/spellkit/version.rb +# 2. git commit -am "Release vX.Y.Z" +# 3. git tag X.Y.Z && git push origin HEAD --tags +# +# Dry-run any time without a tag: +# gh workflow run release.yml -f publish=false --ref + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" # release: 0.1.2 + - "[0-9]+.[0-9]+.[0-9]+.*" # prerelease: 0.1.2.pre1, 0.1.2.rc2 (RubyGems dot form) + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+.*" + workflow_dispatch: + inputs: + publish: + type: boolean + default: false + description: "push to RubyGems (default off = dry-run)" + +# A reusable workflow can only REDUCE the inherited token scope, never elevate it — +# so the caller MUST grant contents: write here (and the repo's default workflow token +# must not be read-only) or `gh release create` 403s. +permissions: + contents: write + +jobs: + release: + uses: scientist-labs/rust-gem-release/.github/workflows/release.yml@v0 + with: + # Generic (non-aliasing) adopter: gem-name == ext-name == gemspec basename == + # "spellkit", so ext-name / gemspec / platform-gem-env all resolve to their + # defaults (spellkit, spellkit.gemspec, RUST_GEM_PLATFORM) and are omitted. + gem-name: spellkit + version-command: ruby -r./lib/spellkit/version -e 'print SpellKit::VERSION' + # On a tag push -> publish; on manual dispatch -> the input (default false = dry-run). + publish: ${{ github.event_name == 'push' || inputs.publish }} + secrets: + # required: false in the shared workflow -> an empty token is a clean no-op skip. + rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/lib/spellkit.rb b/lib/spellkit.rb index 739b627..41df6f9 100644 --- a/lib/spellkit.rb +++ b/lib/spellkit.rb @@ -4,10 +4,17 @@ require "openssl" require "fileutils" +# Load the compiled Rust extension. Precompiled (platform) gems install it into a +# Ruby-ABI-versioned subdir (lib/spellkit//spellkit.{so,bundle}) so a +# single fat gem can carry a binary per Ruby version; source/dev builds place it flat +# at lib/spellkit/spellkit.{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. begin - require "spellkit/spellkit" + RUBY_VERSION =~ /(\d+\.\d+)/ + require "spellkit/#{Regexp.last_match(1)}/spellkit" rescue LoadError - require "spellkit.bundle" + require "spellkit/spellkit" end module SpellKit diff --git a/spellkit.gemspec b/spellkit.gemspec index da8f7a8..2240e20 100644 --- a/spellkit.gemspec +++ b/spellkit.gemspec @@ -32,7 +32,20 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.extensions = ["ext/spellkit/extconf.rb"] + + # Precompiled platform gems (e.g. arm64-darwin, built natively on a macOS runner) + # carry one compiled extension per Ruby ABI under lib/spellkit// 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["RUST_GEM_PLATFORM"]) + spec.platform = platform_gem + spec.extensions = [] + spec.files += Dir["lib/spellkit/*/spellkit.bundle"] + Dir["lib/spellkit/*/spellkit.so"] + else + spec.extensions = ["ext/spellkit/extconf.rb"] + end spec.add_dependency "rb_sys", "~> 0.9" From 97916d0b390e21df5366544fd7fc621bc49a9a36 Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 15:00:26 -0400 Subject: [PATCH 2/5] ci: re-trigger PR-CI against fixed build.yml@v0 (0.4.0) From 33563bc49b0c4e2166cac497fb8bbfbb1a5117e5 Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 15:22:26 -0400 Subject: [PATCH 3/5] ci: harden Rakefile + label-gated dry-run for precompiled linux legs Guard the dev-only rspec require so the Rakefile loads in the rb-sys-dock cross container (RUNTIME bundle only), and declare cross_compile + cross_platform on the rake-compiler ExtensionTask so the shared rust-gem-release matrix can build x86_64-linux / aarch64-linux / arm64-darwin precompiled gems. Caller: add a label-gated pull_request dry-run (types incl. labeled) so a PR labeled dry-run-release validates the full publish=false matrix before merge; gate the calling job on that label; make publish robust so a PR is never a publish (only push tags, or workflow_dispatch with inputs.publish). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 13 ++++++++++++- Rakefile | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65fbd82..e884a48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,11 @@ name: Release # # Dry-run any time without a tag: # gh workflow run release.yml -f publish=false --ref +# +# Pre-merge full-matrix dry-run: add the `dry-run-release` label to a PR. The +# `labeled` trigger below fires the real release pipeline (publish=false) against +# the PR branch, validating every cross leg before merge. workflow_dispatch can't +# reach a branch-only workflow, so the label is the only pre-merge path. on: push: @@ -28,6 +33,8 @@ on: type: boolean default: false description: "push to RubyGems (default off = dry-run)" + pull_request: + types: [opened, synchronize, reopened, labeled] # A reusable workflow can only REDUCE the inherited token scope, never elevate it — # so the caller MUST grant contents: write here (and the repo's default workflow token @@ -37,6 +44,9 @@ permissions: jobs: release: + # Normal PRs must not run the expensive cross matrix — only a PR explicitly + # labeled `dry-run-release` does. Tag pushes and manual dispatch always run. + if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'dry-run-release') }} uses: scientist-labs/rust-gem-release/.github/workflows/release.yml@v0 with: # Generic (non-aliasing) adopter: gem-name == ext-name == gemspec basename == @@ -45,7 +55,8 @@ jobs: gem-name: spellkit version-command: ruby -r./lib/spellkit/version -e 'print SpellKit::VERSION' # On a tag push -> publish; on manual dispatch -> the input (default false = dry-run). - publish: ${{ github.event_name == 'push' || inputs.publish }} + # A labeled-PR dry-run is never a publish (the pull_request event has no `inputs`). + publish: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) }} secrets: # required: false in the shared workflow -> an empty token is a clean no-op skip. rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/Rakefile b/Rakefile index 61d36ea..f923c9f 100644 --- a/Rakefile +++ b/Rakefile @@ -1,13 +1,25 @@ require "bundler/gem_tasks" -require "rspec/core/rake_task" require "rake/extensiontask" -RSpec::Core::RakeTask.new(:spec) +# rspec is a dev-only dependency. The rb-sys-dock cross container installs the +# RUNTIME bundle only, so this require must not be allowed to break Rakefile load +# during the linux cross build. +begin + require "rspec/core/rake_task" + RSpec::Core::RakeTask.new(:spec) +rescue LoadError + desc "spec (rspec unavailable here)" + task(:spec) { abort "rspec is a dev dependency" } +end Rake::ExtensionTask.new("spellkit") do |ext| ext.lib_dir = "lib/spellkit" ext.ext_dir = "ext/spellkit" ext.source_pattern = "*.{rs,toml}" + # Precompiled cross-gem build (x86_64-linux + aarch64-linux via rb-sys-dock, + # arm64-darwin natively on macOS) for the shared rust-gem-release matrix. + ext.cross_compile = true + ext.cross_platform = %w[x86_64-linux aarch64-linux arm64-darwin] end task default: [:compile, :spec] From 975e199a2ac16d149ee78e6dc54c08564435c41c Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 16:07:28 -0400 Subject: [PATCH 4/5] Rakefile: pass gemspec to ExtensionTask so native: cross tasks exist --- Rakefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index f923c9f..0ba7cda 100644 --- a/Rakefile +++ b/Rakefile @@ -12,7 +12,12 @@ rescue LoadError task(:spec) { abort "rspec is a dev dependency" } end -Rake::ExtensionTask.new("spellkit") do |ext| +# Pass the gemspec so rake-compiler defines the native: gem tasks that +# rb-sys-dock invokes for each precompiled leg (without it only compile tasks +# exist and the cross build fails with "Don't know how to build task native:..."). +GEMSPEC = Gem::Specification.load("spellkit.gemspec") + +Rake::ExtensionTask.new("spellkit", GEMSPEC) do |ext| ext.lib_dir = "lib/spellkit" ext.ext_dir = "ext/spellkit" ext.source_pattern = "*.{rs,toml}" From ce0d981a6c55bc9cdf2949ebfb92251f86053f21 Mon Sep 17 00:00:00 2001 From: Xavier Lange Date: Thu, 18 Jun 2026 19:09:22 -0400 Subject: [PATCH 5/5] Pin rust-gem-release reusable workflows to @0.10.0 Retire the moving @v0 tag in favor of the explicit latest release. Updates both release.yml and build.yml callers and their doc comments. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af33ba4..6e3262d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,7 @@ name: build # Thin PR-CI caller for the shared scientist-labs/rust-gem-release build workflow -# (pinned @v0). Supersedes the hand-rolled ci.yml: it compiles the native extension +# (pinned @0.10.0). Supersedes the hand-rolled ci.yml: it compiles the native extension # and runs the spec suite across the supported Ruby versions, with cache + setup-ruby # centralized in the reusable workflow. @@ -9,6 +9,6 @@ on: pull_request jobs: build: - uses: scientist-labs/rust-gem-release/.github/workflows/build.yml@v0 + uses: scientist-labs/rust-gem-release/.github/workflows/build.yml@0.10.0 with: test-command: bundle exec rake spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e884a48..2c91378 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ name: Release # It owns only what an `on: workflow_call` workflow cannot: the tag trigger, the # contents:write token grant, and spellkit's per-gem inputs/secrets. Every build # leg (source + arm64-darwin + x86_64-linux + aarch64-linux), SHA pin, and gotcha -# lives once in the shared workflow (pinned @v0 = moving major). See red-candle's +# lives once in the shared workflow (pinned @0.10.0). See red-candle's # release.yml for the proven template. # # Release procedure (the tag drives it; a CI-created tag would not re-trigger): @@ -47,7 +47,7 @@ jobs: # Normal PRs must not run the expensive cross matrix — only a PR explicitly # labeled `dry-run-release` does. Tag pushes and manual dispatch always run. if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'dry-run-release') }} - uses: scientist-labs/rust-gem-release/.github/workflows/release.yml@v0 + uses: scientist-labs/rust-gem-release/.github/workflows/release.yml@0.10.0 with: # Generic (non-aliasing) adopter: gem-name == ext-name == gemspec basename == # "spellkit", so ext-name / gemspec / platform-gem-env all resolve to their