Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/actions/cibuildgem/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/e2e-dummy-gem.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
19 changes: 13 additions & 6 deletions lib/cibuildgem/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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!(

@girachawda girachawda Oct 9, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OughtPuts I know we discussed explicitly running the rake task twice here, once for the fat and then the skinny. But it seems that rake compiler already schedules that order here. So calling run rake task here with the single abi flag we would essentially trigger multi ABI, multi ABI + skinny. Did you have a specific reason in mind as to specifically calling the run rake task twice here? Maybe to make sure multi ABI executes first? I just think it is going to take extra time to run rake task twice for multi ABI twice. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I had another look at the rake-compiler implementation this morning and we're all good, how we've set things up here will result in the correct gems (single and multi) being built with rake-compiler without any additional iteration. I just couldn't remember how rake-compiler worked when we paired! Sorry for any confusion.

"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"
Expand Down Expand Up @@ -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"]
Expand All @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions lib/cibuildgem/compilation_tasks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@

module Cibuildgem
class CompilationTasks
attr_reader :gemspec, :native, :create_packaging_task, :extension_task
CONTENT_ADDRESSABLE_ENV = "CIBUILDGEM_CONTENT_ADDRESSABLE"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you definitely convinced we need this assignment? It feels a bit overcomplicated and hard to quickly understand to me.


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
Expand Down Expand Up @@ -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.")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in Slack, it would be even better to provide a rake-compiler version here to give the user more of a steer of what they need.

end
end

Rake::ExtensionTask.enable!
@extension_task.define
Expand Down
113 changes: 113 additions & 0 deletions test/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

module Cibuildgem
class CLITest < Minitest::Test
TEST_CONTENT_ADDRESSABLE_ENV = "CIBUILDGEM_CONTENT_ADDRESSABLE"

def setup
super

Expand All @@ -15,6 +17,7 @@ def setup

def teardown
ENV.delete("RUBY_CC_VERSION")
ENV.delete(TEST_CONTENT_ADDRESSABLE_ENV)

super
end
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this naming might be out of date. We want to check if the package call defaults to false right rather than multi_abi.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a little clunky - can we combine this test with the first test or does it definitely have to be separate?

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
Expand Down
Loading