From 6e2bc090568fcbce3b094a199adf9c10a4eb6b3b Mon Sep 17 00:00:00 2001 From: Gira Chawda <64598936+girachawda@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:49:34 -0400 Subject: [PATCH 1/2] Add support for packaging single-ABI gems --- .github/actions/cibuildgem/action.yml | 27 +++++- .github/workflows/e2e-dummy-gem.yml | 2 + lib/cibuildgem/cli.rb | 19 +++-- lib/cibuildgem/compilation_tasks.rb | 19 ++++- test/cli_test.rb | 113 ++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 9 deletions(-) diff --git a/.github/actions/cibuildgem/action.yml b/.github/actions/cibuildgem/action.yml index 29afc5d25..5a1d86f89 100644 --- a/.github/actions/cibuildgem/action.yml +++ b/.github/actions/cibuildgem/action.yml @@ -9,6 +9,10 @@ inputs: step: description: "The step to run" required: true + include-single-abi: + description: "Whether to also compile a set of single-ABI gems." + required: false + default: false test-command: description: "The command to run the test suite. By default cibuildgem will run either `bundle exec rake test` or `bundle exec rake spec` depending on the test framework used." required: false @@ -48,7 +52,7 @@ runs: if: "${{ inputs.step == 'compile' }}" working-directory: ${{ inputs.working-directory }} shell: bash - run: cibuildgem package + run: cibuildgem package --include-single-abi "${{ inputs.include-single-abi }}" - name: Upload artifacts if: "${{ inputs.step == 'compile' }}" uses: actions/upload-artifact@v4 @@ -66,6 +70,27 @@ runs: with: name: "stage-${{ runner.os }}-${{ runner.arch }}" path: "${{ inputs.working-directory }}" + - name: "Install single-ABI gem" + if: "${{ inputs.step == 'test_cross' && inputs.include-single-abi == 'true' }}" + working-directory: ${{ inputs.working-directory }} + shell: bash + run: | + ruby <<'RUBY' > /tmp/single-abi-gems + require "rubygems/package" + + ruby_abi = RUBY_VERSION.split(".").first(2).join(".") + expected_requirement = Gem::Requirement.new("~> #{ruby_abi}.0") + gems = Dir["pkg/*.gem"].select do |path| + spec = Gem::Package.new(path).spec + spec.platform != Gem::Platform::RUBY && spec.required_ruby_version == expected_requirement + end + + abort "No single-ABI gem found for Ruby #{ruby_abi}" if gems.empty? + + puts gems + RUBY + + xargs gem install < /tmp/single-abi-gems - name: "Copy staging binary to the libdir" if: "${{ inputs.step == 'test_cross' }}" working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/e2e-dummy-gem.yml b/.github/workflows/e2e-dummy-gem.yml index 321ccac5b..8c05a87ce 100644 --- a/.github/workflows/e2e-dummy-gem.yml +++ b/.github/workflows/e2e-dummy-gem.yml @@ -26,6 +26,7 @@ jobs: with: step: "compile" working-directory: "test/fixtures/dummy_gem" + include-single-abi: true test: timeout-minutes: 20 name: "Run the test suite" @@ -53,6 +54,7 @@ jobs: with: step: "test_${{ matrix.type }}" working-directory: "test/fixtures/dummy_gem" + include-single-abi: true install: timeout-minutes: 5 name: "Verify the gem can be installed" diff --git a/lib/cibuildgem/cli.rb b/lib/cibuildgem/cli.rb index a44d3a5d7..b583d2a34 100644 --- a/lib/cibuildgem/cli.rb +++ b/lib/cibuildgem/cli.rb @@ -36,7 +36,7 @@ def compile run_rake_tasks!("cibuildgem:setup", :compile) end - desc "package", "Compile and package a 'fat gem'.", hide: true + desc "package", "Compile and package a 'multi-ABI gem'.", hide: true long_desc <<~MSG This command should normally run on CI, using the cibuildgem workflow. It will not work locally unless the environment is properly setup. @@ -46,13 +46,20 @@ def compile - A gem without precompiled binary (Ruby platform). The gem with precompiled binaries will be packaged with multiple binaries compatible for different - Ruby ABI (depending on what Ruby version the gem supports). + Ruby ABI (depending on what Ruby version the gem supports). If `--include-single-abi` is passed, + cibuildgem will also compile a set of single-ABI gems per Ruby version supported by the gem. MSG method_option "gemspec", type: "string", required: false, desc: "The gemspec to use. Defaults to the gemspec from the current working directory." + method_option "include-single-abi", type: :boolean, required: false, default: false, desc: "Whether to also compile a set of single-ABI gems. Defaults to false." def package ENV["RUBY_CC_VERSION"] ||= compilation_task.ruby_cc_version - - run_rake_tasks!("cibuildgem:setup", :cross, :native, :gem) + run_rake_tasks!( + "cibuildgem:setup", + :cross, + :native, + :gem, + env: { CompilationTasks::CONTENT_ADDRESSABLE_ENV => options["include-single-abi"] == true ? "true" : "false" }, + ) end desc "test", "Run the test suites of the target gem" @@ -158,7 +165,7 @@ def verify_releasable_filename!(pathname) MSG end - def run_rake_tasks!(*tasks) + def run_rake_tasks!(*tasks, env: {}) rakelibdir = [File.expand_path("tasks", __dir__), "rakelib"].join(File::PATH_SEPARATOR) rake_compiler_path = Gem.loaded_specs["rake-compiler"].full_require_paths rake_specs = Gem.loaded_specs["rake"] @@ -169,7 +176,7 @@ def run_rake_tasks!(*tasks) patch = File.expand_path("../extension_patch.rb", __FILE__) system( - { "RUBYLIB" => load_paths }, + { "RUBYLIB" => load_paths }.merge(env), "bundle", "exec", RbConfig.ruby, diff --git a/lib/cibuildgem/compilation_tasks.rb b/lib/cibuildgem/compilation_tasks.rb index e19d6ecd7..81b308c43 100644 --- a/lib/cibuildgem/compilation_tasks.rb +++ b/lib/cibuildgem/compilation_tasks.rb @@ -7,13 +7,20 @@ module Cibuildgem class CompilationTasks - attr_reader :gemspec, :native, :create_packaging_task, :extension_task + CONTENT_ADDRESSABLE_ENV = "CIBUILDGEM_CONTENT_ADDRESSABLE" - def initialize(create_packaging_task = false, gemspec = nil) + attr_reader :gemspec, :native, :create_packaging_task, :extension_task, :content_addressable + + def initialize( + create_packaging_task = false, + gemspec = nil, + content_addressable: ENV[CONTENT_ADDRESSABLE_ENV] == "true" + ) @gemspec = Bundler.load_gemspec(gemspec || find_gemspec) verify_gemspec! @create_packaging_task = create_packaging_task + @content_addressable = content_addressable end def setup @@ -62,6 +69,14 @@ def define_task(path, binary_name) @extension_task.cross_platform = normalized_platform @extension_task.cross_compile = true @extension_task.no_native = true + if content_addressable + if @extension_task.respond_to?(:content_addressable=) + @extension_task.content_addressable = true + else + warn("WARNING: content-addressable packaging was requested, but rake-compiler does not support it. " \ + "Upgrade rake-compiler to build single-ABI gems.") + end + end Rake::ExtensionTask.enable! @extension_task.define diff --git a/test/cli_test.rb b/test/cli_test.rb index c2d9e690a..2a7c1f874 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -7,6 +7,8 @@ module Cibuildgem class CLITest < Minitest::Test + TEST_CONTENT_ADDRESSABLE_ENV = "CIBUILDGEM_CONTENT_ADDRESSABLE" + def setup super @@ -15,6 +17,7 @@ def setup def teardown ENV.delete("RUBY_CC_VERSION") + ENV.delete(TEST_CONTENT_ADDRESSABLE_ENV) super end @@ -328,6 +331,116 @@ def test_package_when_a_rakefile_defines_an_extension_task assert_predicate($CHILD_STATUS, :success?) end + def test_package_single_abi_enables_content_addressable_packaging_for_one_rake_run + cli = CLI.new([], { "include-single-abi" => true }) + compilation_task = Struct.new(:ruby_cc_version).new("3.3.8:3.2.8") + rake_calls = [] + run_rake_tasks = proc do |*tasks, env: {}| + rake_calls << [ + ENV.fetch("RUBY_CC_VERSION"), + env.fetch(TEST_CONTENT_ADDRESSABLE_ENV), + tasks, + ] + end + + cli.stub(:compilation_task, compilation_task) do + cli.stub(:run_rake_tasks!, run_rake_tasks) do + cli.package + end + end + + assert_equal( + [["3.3.8:3.2.8", "true", ["cibuildgem:setup", :cross, :native, :gem]]], + rake_calls, + ) + assert_nil(ENV[TEST_CONTENT_ADDRESSABLE_ENV]) + end + + def test_package_defaults_to_multi_abi_and_preserves_ruby_cc_version_from_environment + ENV["RUBY_CC_VERSION"] = "3.1.6" + cli = CLI.new + compilation_task = Struct.new(:ruby_cc_version).new("3.3.8:3.2.8") + rake_calls = [] + run_rake_tasks = proc do |*tasks, env: {}| + rake_calls << [ + ENV.fetch("RUBY_CC_VERSION"), + env.fetch(TEST_CONTENT_ADDRESSABLE_ENV), + tasks, + ] + end + + cli.stub(:compilation_task, compilation_task) do + cli.stub(:run_rake_tasks!, run_rake_tasks) do + cli.package + end + end + + assert_equal( + [["3.1.6", "false", ["cibuildgem:setup", :cross, :native, :gem]]], + rake_calls, + ) + end + + def test_compilation_task_enables_content_addressable_extension_task + require "cibuildgem/extension_patch" + + extension_task = Class.new do + attr_accessor :config_script, + :content_addressable, + :cross_compile, + :cross_platform, + :ext_dir, + :gem_spec, + :lib_dir, + :name, + :no_native + + def define; end + end.new + original_current = Rake::ExtensionTask.current + + Dir.chdir("test/fixtures/dummy_gem") do + Rake::ExtensionTask.current = extension_task + CompilationTasks.new(false, nil, content_addressable: true).setup + end + + assert_equal(true, extension_task.content_addressable) + ensure + Rake::ExtensionTask.current = original_current if Rake::ExtensionTask.respond_to?(:current=) + Rake::Task.clear + end + + def test_compilation_task_warns_when_rake_compiler_does_not_support_content_addressable + require "cibuildgem/extension_patch" + + extension_task = Class.new do + attr_accessor :config_script, + :cross_compile, + :cross_platform, + :ext_dir, + :gem_spec, + :lib_dir, + :name, + :no_native + + def define; end + end.new + original_current = Rake::ExtensionTask.current + + _, err = capture_io do + Dir.chdir("test/fixtures/dummy_gem") do + Rake::ExtensionTask.current = extension_task + CompilationTasks.new(false, nil, content_addressable: true).setup + end + end + + assert_includes(err, "content-addressable packaging was requested") + assert_includes(err, "Upgrade rake-compiler") + ensure + Rake::ExtensionTask.current = original_current if Rake::ExtensionTask.respond_to?(:current=) + Rake::Task.clear + end + def test_keep_the_extension_task_config_defined_by_the_gem Dir.chdir("test/fixtures/with_configured_ext") do cli = CLI.new From e6ea08b5765dcfe05899a80b1bbfb51640fac7e9 Mon Sep 17 00:00:00 2001 From: Gira Chawda <64598936+girachawda@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:49:35 -0400 Subject: [PATCH 2/2] Address single-ABI PR review feedback --- .github/actions/cibuildgem/action.yml | 25 ++++++++- .github/workflows/date-gem-compile.yml | 5 +- .github/workflows/e2e-dummy-gem.yml | 2 +- lib/cibuildgem/cli.rb | 2 +- lib/cibuildgem/compilation_tasks.rb | 12 ++-- test/cli_test.rb | 76 +++++++++++--------------- 6 files changed, 68 insertions(+), 54 deletions(-) diff --git a/.github/actions/cibuildgem/action.yml b/.github/actions/cibuildgem/action.yml index 5a1d86f89..18811a70a 100644 --- a/.github/actions/cibuildgem/action.yml +++ b/.github/actions/cibuildgem/action.yml @@ -52,7 +52,10 @@ runs: if: "${{ inputs.step == 'compile' }}" working-directory: ${{ inputs.working-directory }} shell: bash - run: cibuildgem package --include-single-abi "${{ inputs.include-single-abi }}" + env: + INCLUDE_SINGLE_ABI: "${{ inputs.include-single-abi }}" + run: | + if [ "$INCLUDE_SINGLE_ABI" = "true" ]; then cibuildgem package --include-single-abi; else cibuildgem package; fi - name: Upload artifacts if: "${{ inputs.step == 'compile' }}" uses: actions/upload-artifact@v4 @@ -119,7 +122,25 @@ runs: - name: "Install gems" if: "${{ inputs.step == 'install' }}" shell: bash - run: gem install pkg/*.gem + run: | + ruby <<'RUBY' | xargs gem install + require "rubygems/package" + + content_addressed_gem = /-[0-9a-f]{8,64}\.gem\z/ + + multi_abi_gems = Dir["pkg/*.gem"].select do |path| + spec = Gem::Package.new(path).spec + + platform_gem = spec.platform != Gem::Platform::RUBY + content_addressed = File.basename(path).match?(content_addressed_gem) + + platform_gem && !content_addressed + end + + abort "No multi-ABI platform gem found" if multi_abi_gems.empty? + + puts multi_abi_gems + RUBY - name: "Configure RubyGems crendentials in preparation for publishing" if: "${{ inputs.step == 'release' }}" uses: rubygems/configure-rubygems-credentials@v1.0.0 diff --git a/.github/workflows/date-gem-compile.yml b/.github/workflows/date-gem-compile.yml index 79af5da93..53cd29735 100644 --- a/.github/workflows/date-gem-compile.yml +++ b/.github/workflows/date-gem-compile.yml @@ -28,14 +28,17 @@ jobs: - name: "Setup Ruby" uses: "ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71" with: - ruby-version: "3.1.7" + ruby-version: "3.4.7" bundler-cache: true working-directory: "test/fixtures/date" + - name: "Update RubyGems" + run: gem update --system 4.1.0.beta2 --prerelease - name: "Run cibuildgem" uses: "./.github/actions/cibuildgem" with: step: "compile" working-directory: "test/fixtures/date" + include-single-abi: true test: timeout-minutes: 30 name: "Run the test suite" diff --git a/.github/workflows/e2e-dummy-gem.yml b/.github/workflows/e2e-dummy-gem.yml index 8c05a87ce..8102f8ad7 100644 --- a/.github/workflows/e2e-dummy-gem.yml +++ b/.github/workflows/e2e-dummy-gem.yml @@ -54,7 +54,7 @@ jobs: with: step: "test_${{ matrix.type }}" working-directory: "test/fixtures/dummy_gem" - include-single-abi: true + include-single-abi: ${{ matrix.rubies == '3.4' }} install: timeout-minutes: 5 name: "Verify the gem can be installed" diff --git a/lib/cibuildgem/cli.rb b/lib/cibuildgem/cli.rb index b583d2a34..ac5bf7740 100644 --- a/lib/cibuildgem/cli.rb +++ b/lib/cibuildgem/cli.rb @@ -58,7 +58,7 @@ def package :cross, :native, :gem, - env: { CompilationTasks::CONTENT_ADDRESSABLE_ENV => options["include-single-abi"] == true ? "true" : "false" }, + env: { "CIBUILDGEM_CONTENT_ADDRESSABLE" => options["include-single-abi"] == true ? "true" : "false" }, ) end diff --git a/lib/cibuildgem/compilation_tasks.rb b/lib/cibuildgem/compilation_tasks.rb index 81b308c43..06f555cb7 100644 --- a/lib/cibuildgem/compilation_tasks.rb +++ b/lib/cibuildgem/compilation_tasks.rb @@ -7,14 +7,12 @@ module Cibuildgem class CompilationTasks - CONTENT_ADDRESSABLE_ENV = "CIBUILDGEM_CONTENT_ADDRESSABLE" - attr_reader :gemspec, :native, :create_packaging_task, :extension_task, :content_addressable def initialize( create_packaging_task = false, gemspec = nil, - content_addressable: ENV[CONTENT_ADDRESSABLE_ENV] == "true" + content_addressable: ENV["CIBUILDGEM_CONTENT_ADDRESSABLE"] == "true" ) @gemspec = Bundler.load_gemspec(gemspec || find_gemspec) verify_gemspec! @@ -73,8 +71,8 @@ def define_task(path, binary_name) if @extension_task.respond_to?(:content_addressable=) @extension_task.content_addressable = true else - warn("WARNING: content-addressable packaging was requested, but rake-compiler does not support it. " \ - "Upgrade rake-compiler to build single-ABI gems.") + warn("WARNING: content-addressable packaging was requested, but rake-compiler #{rake_compiler_version} " \ + "does not support it. Upgrade rake-compiler to >= 1.3.2 to build single-ABI gems.") end end @@ -84,6 +82,10 @@ def define_task(path, binary_name) disable_shared unless Gem.win_platform? end + def rake_compiler_version + Gem.loaded_specs["rake-compiler"]&.version&.to_s || "unknown" + end + def binary_lib_dir(binary_name) dir = File.dirname(binary_name) return if dir == "." diff --git a/test/cli_test.rb b/test/cli_test.rb index 2a7c1f874..e1bbc12ff 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -331,54 +331,42 @@ def test_package_when_a_rakefile_defines_an_extension_task assert_predicate($CHILD_STATUS, :success?) end - def test_package_single_abi_enables_content_addressable_packaging_for_one_rake_run - cli = CLI.new([], { "include-single-abi" => true }) - compilation_task = Struct.new(:ruby_cc_version).new("3.3.8:3.2.8") - rake_calls = [] - run_rake_tasks = proc do |*tasks, env: {}| - rake_calls << [ - ENV.fetch("RUBY_CC_VERSION"), - env.fetch(TEST_CONTENT_ADDRESSABLE_ENV), - tasks, - ] - end - - cli.stub(:compilation_task, compilation_task) do - cli.stub(:run_rake_tasks!, run_rake_tasks) do - cli.package + def test_package_sets_content_addressable_env_from_include_single_abi_option + test_cases = [ + [{ "include-single-abi" => true }, nil, "3.3.8:3.2.8", "true"], + [{}, "3.1.6", "3.1.6", "false"], + ] + + test_cases.each do |test_case| + options, existing_ruby_cc_version, expected_ruby_cc_version, expected_content_addressable = test_case + ENV["RUBY_CC_VERSION"] = existing_ruby_cc_version + cli = CLI.new([], options) + compilation_task = Struct.new(:ruby_cc_version).new("3.3.8:3.2.8") + rake_calls = [] + run_rake_tasks = proc do |*tasks, env: {}| + rake_calls << [ + ENV.fetch("RUBY_CC_VERSION"), + env.fetch(TEST_CONTENT_ADDRESSABLE_ENV), + tasks, + ] end - end - - assert_equal( - [["3.3.8:3.2.8", "true", ["cibuildgem:setup", :cross, :native, :gem]]], - rake_calls, - ) - assert_nil(ENV[TEST_CONTENT_ADDRESSABLE_ENV]) - end - def test_package_defaults_to_multi_abi_and_preserves_ruby_cc_version_from_environment - ENV["RUBY_CC_VERSION"] = "3.1.6" - cli = CLI.new - compilation_task = Struct.new(:ruby_cc_version).new("3.3.8:3.2.8") - rake_calls = [] - run_rake_tasks = proc do |*tasks, env: {}| - rake_calls << [ - ENV.fetch("RUBY_CC_VERSION"), - env.fetch(TEST_CONTENT_ADDRESSABLE_ENV), - tasks, - ] - end - - cli.stub(:compilation_task, compilation_task) do - cli.stub(:run_rake_tasks!, run_rake_tasks) do - cli.package + cli.stub(:compilation_task, compilation_task) do + cli.stub(:run_rake_tasks!, run_rake_tasks) do + cli.package + end end - end - assert_equal( - [["3.1.6", "false", ["cibuildgem:setup", :cross, :native, :gem]]], - rake_calls, - ) + assert_equal( + [[ + expected_ruby_cc_version, + expected_content_addressable, + ["cibuildgem:setup", :cross, :native, :gem], + ]], + rake_calls, + ) + assert_nil(ENV[TEST_CONTENT_ADDRESSABLE_ENV]) + end end def test_compilation_task_enables_content_addressable_extension_task