diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6e3262d --- /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 @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. + +on: pull_request + +jobs: + build: + 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/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..2c91378 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +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 @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): +# 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 +# +# 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: + 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)" + 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 +# must not be read-only) or `gh release create` 403s. +permissions: + contents: write + +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@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 + # 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). + # 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..0ba7cda 100644 --- a/Rakefile +++ b/Rakefile @@ -1,13 +1,30 @@ 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 + +# 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") do |ext| +Rake::ExtensionTask.new("spellkit", GEMSPEC) 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] 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"