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 96f61aa30..8e2010472 100644 --- a/.github/workflows/e2e-dummy-gem.yml +++ b/.github/workflows/e2e-dummy-gem.yml @@ -24,6 +24,7 @@ jobs: with: step: "compile" working-directory: "test/fixtures/dummy_gem" + include-single-abi: true test: timeout-minutes: 20 name: "Run the test suite" @@ -48,6 +49,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