Skip to content
Draft
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
32 changes: 29 additions & 3 deletions lib/rubygems/commands/build_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ def initialize
options[:output] = value
end

add_ruby_abi_option("build", " (builds a content addressable gem)")
add_ruby_abi_option("build")

add_option "--content-addressable", "build a content-addressable gem" do |_value, options|
options[:content_addressable] = true
end
end

def arguments # :nodoc:
Expand Down Expand Up @@ -54,10 +58,19 @@ def description # :nodoc:

$ gem build my_gem-1.0.gemspec --output=release.gem

Platform gems can be built for a single Ruby ABI with the --ruby-abi option:
Use the --ruby-abi option to set a specific Ruby ABI for the gem being built:

$ gem build my_gem-1.0.gemspec --ruby-abi=3.4

Use the --content-addressable option to build a content-addressable gem using
the platform and required_ruby_version declared in the gemspec:

$ gem build my_gem-1.0.gemspec --content-addressable

The options can be combined to set the Ruby ABI explicitly:

$ gem build my_gem-1.0.gemspec --ruby-abi=3.4 --content-addressable

EOF
end

Expand Down Expand Up @@ -95,12 +108,25 @@ def build_package(gemspec)
spec.platform = Gem::Platform.local
end

ruby_abi = options[:ruby_abi]
if ruby_abi
requirement = Gem::ContentAddress.ruby_abi_requirement(ruby_abi)
existing = spec.required_ruby_version

unless existing.none? || existing == requirement
alert_warning "required_ruby_version was changed from \"#{existing}\" to \"#{requirement}\" for this build " \
"because --ruby-abi #{ruby_abi} was given."
end

spec.required_ruby_version = requirement
end

Gem::Package.build(
spec,
options[:force],
options[:strict],
options[:output],
options[:ruby_abi]
content_addressable: options[:content_addressable]
)
else
alert_error "Error loading gemspec. Aborting."
Expand Down
31 changes: 17 additions & 14 deletions lib/rubygems/content_address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ def self.eligible?(spec, validate_ruby_abi: true)
!ruby_abi_for(spec.required_ruby_version).nil?
end

##
# Raises ArgumentError unless +spec+ is eligible for content addressing

def self.eligible!(spec)
unless platform_eligible?(spec.platform)
raise ArgumentError,
"Cannot build a content-addressable gem as no platform or a Ruby platform has been set"
end

return unless ruby_abi_for(spec.required_ruby_version).nil?

raise ArgumentError,
"Cannot build a content-addressable gem because required_ruby_version is set to " \
"#{spec.required_ruby_version}. Please set required_ruby_version to \"~> X.Y.0\" " \
"so that it identifies a single Ruby ABI."
end

##
# Whether +spec+ is content-addressed: it is eligible for content
# addressing and has a valid content address set. See eligible? for
Expand All @@ -114,20 +131,6 @@ def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, v
!ruby_abi_for(required_ruby_version).nil?
end

##
# Whether +spec+'s required_ruby_version permits building for +ruby_abi+:
# an unset or default requirement can still be pinned to the ABI, and
# anything else must already pin exactly that ABI. Used at build time,
# before the requirement is injected, where eligible? would be
# premature.

def self.ruby_abi_compatible?(spec, ruby_abi)
required_ruby_version = spec.required_ruby_version
return true if required_ruby_version.nil? || required_ruby_version.none?

ruby_abi_for(required_ruby_version) == ruby_abi
end

##
# Generates the content address for +bytes+: the first +length+
# characters of the hexadecimal SHA256 digest of the contents.
Expand Down
65 changes: 24 additions & 41 deletions lib/rubygems/package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,20 @@ class TarInvalidError < Error; end
MINIMUM_RUBYGEMS_VERSION = ">= 4.1.0.a"

##
# Builds the gem described by +spec+ and returns the built file name;
# passing +ruby_abi+ ("X.Y") builds a content-addressable gem named by the
# SHA-256 of its contents, updates +spec.required_ruby_version+ to
# "~> X.Y.0", and constrains +spec.required_rubygems_version+ to at least
# MINIMUM_RUBYGEMS_VERSION (incompatible with
# +file_name+).

def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil, ruby_abi = nil)
if ruby_abi && file_name
raise ArgumentError, "Cannot specify both a Ruby ABI and an output file name because content addressable gems must use the generated file name."
end
if ruby_abi
# Builds the gem described by +spec+ and returns the built file name.
#
# When +content_addressable+ is true, builds a gem named by the SHA-256
# digest of its contents. The spec must declare a non-Ruby platform and a
# +required_ruby_version+ identifying a single Ruby ABI. Its
# +required_rubygems_version+ is constrained to at least
# MINIMUM_RUBYGEMS_VERSION. Content-addressable builds are incompatible
# with +file_name+.

def self.build(spec, skip_validation = false, strict_validation = false, file_name = nil, content_addressable: false)
if content_addressable && file_name
raise ArgumentError, "Cannot specify an output file name for a content-addressable gem as these gems must use the generated file name."
end
if content_addressable
require "digest"
require "stringio"

Expand All @@ -157,9 +159,8 @@ def self.build(spec, skip_validation = false, strict_validation = false, file_na

package = new io
package.spec = spec.dup
gem_file = package.build_content_addressable_file ruby_abi, skip_validation, strict_validation
gem_file = package.build_content_addressable_file skip_validation, strict_validation

spec.required_ruby_version = package.spec.required_ruby_version
spec.required_rubygems_version = package.spec.required_rubygems_version
else
gem_file = file_name || spec.file_name
Expand Down Expand Up @@ -375,20 +376,18 @@ def build(skip_validation = false, strict_validation = false)
end

##
# Builds this package scoped to +ruby_abi+ ("X.Y"), then writes it to a
# content-addressable file name derived from the SHA-256 digest of the gem
# contents, e.g. "example-1.0-01234567.gem". Returns the file name of the
# written gem.
# Builds this package and writes it to a content-addressable file name
# derived from the SHA-256 digest of the gem contents, for example
# "example-1.0-01234567.gem". Returns the written file name.
#
# The spec is validated for an ABI-scoped build and its
# +required_ruby_version+ and +required_rubygems_version+ are constrained
# before building, so every gem this method produces is eligible for
# content addressing.
# The spec is validated for content addressing. Its
# +required_ruby_version+ determines the Ruby ABI, while its
# +required_rubygems_version+ is constrained before building.

def build_content_addressable_file(ruby_abi, skip_validation = false, strict_validation = false)
validate_ruby_abi ruby_abi
def build_content_addressable_file(skip_validation = false, strict_validation = false)
Gem::ContentAddress.eligible!(@spec)
ruby_abi = Gem::ContentAddress.ruby_abi_for(@spec.required_ruby_version)
@spec.required_rubygems_version = normalized_required_rubygems_version(ruby_abi)
@spec.required_ruby_version = Gem::ContentAddress.ruby_abi_requirement(ruby_abi)

build skip_validation, strict_validation

Expand Down Expand Up @@ -789,22 +788,6 @@ def satisfies_rubygems_floor?(requirement, floor)
end
end

##
# Validates that the spec can be built as a content-addressable gem scoped
# to +ruby_abi+ ("X.Y"): the ABI must be well-formed, the spec must declare
# a non-Ruby platform, and any existing +required_ruby_version+ must match
# the ABI.

def validate_ruby_abi(ruby_abi)
if !Gem::ContentAddress.valid_ruby_abi?(ruby_abi)
raise ArgumentError, "Ruby ABI must be in X.Y format"
elsif !Gem::ContentAddress.platform_eligible?(@spec.platform)
raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set"
elsif !Gem::ContentAddress.ruby_abi_compatible?(@spec, ruby_abi)
raise ArgumentError, "Cannot build gem for Ruby ABI #{ruby_abi} because required_ruby_version is set to #{@spec.required_ruby_version}. Please set required_ruby_version to \"~> #{ruby_abi}.0\"."
end
end

##
# Returns the full path for installing +filename+ into +destination_dir+,
# which must already be resolved with File.realpath by the caller.
Expand Down
81 changes: 75 additions & 6 deletions lib/rubygems/package_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class Gem::PackageTask < Rake::PackageTask

attr_accessor :gem_spec

##
# Indicates whether this package should be built as a content-addressable gem.
attr_accessor :content_addressable

##
# Create a Gem Package task library. Automatically define the gem if a
# block is given. If no block is supplied, then #define needs to be called
Expand All @@ -82,6 +86,7 @@ def initialize(gem_spec)
def init(gem)
super gem.full_name, :noversion
@gem_spec = gem
@content_addressable = false
@package_files += gem_spec.files if gem_spec.files
@fileutils_output = $stdout
end
Expand All @@ -91,33 +96,97 @@ def init(gem)
# (+define+ is automatically called if a block is given to +new+).

def define
Gem::ContentAddress.eligible!(gem_spec) if content_addressable

super

gem_file = File.basename gem_spec.cache_file
gem_path = File.join package_dir, gem_file
gem_dir = File.join package_dir, gem_spec.full_name
Comment thread
OughtPuts marked this conversation as resolved.
gem_dir = package_dir_path

if content_addressable
build_target = stamp_file
gem_description = "content-addressable gem #{gem_spec.name}-#{gem_spec.version} " \
"(Platform: #{gem_spec.platform}, Ruby ABI: #{content_addressable_ruby_abi})"

discard_stale_stamp_file
else
build_target = gem_path
gem_description = "gem file #{gem_file}"
end

task package: [:gem]

directory package_dir
directory gem_dir

desc "Build the gem file #{gem_file}"
task gem: [gem_path]
desc "Build the #{gem_description}"
task gem: [build_target]

trace = Rake.application.options.trace
Gem.configuration.verbose = trace

file gem_path => [package_dir, gem_dir] + @gem_spec.files do
file build_target => [package_dir, gem_dir] + @gem_spec.files do
previous_gem = recorded_gem_path if content_addressable
built_gem_path = nil

chdir(gem_dir) do
when_writing "Creating #{gem_spec.file_name}" do
built_gem_file = Gem::Package.build gem_spec
when_writing "Creating #{gem_description}" do
built_gem_file = Gem::Package.build gem_spec, content_addressable: content_addressable

verbose trace do
mv built_gem_file, ".."
end

if content_addressable
built_gem_path = File.join(package_dir, built_gem_file)
File.write File.join("..", File.basename(stamp_file)), built_gem_path
end
end
end

if previous_gem && built_gem_path && previous_gem != built_gem_path
verbose trace do
rm_f previous_gem
end
end
end
end

##
# The name of this package. Content-addressable builds append the Ruby ABI
# so per-ABI builds of the same +full_name+ do not share a staging
# directory.

def package_name
name = super
ruby_abi = content_addressable_ruby_abi
ruby_abi ? "#{name}-#{ruby_abi}" : name
end

private

def content_addressable_ruby_abi
return unless content_addressable

Gem::ContentAddress.ruby_abi_for(gem_spec.required_ruby_version)
end

def stamp_file
File.join package_dir, "#{package_name}.gem-built"
end

def recorded_gem_path
return unless File.file?(stamp_file)

path = File.read(stamp_file).strip
path unless path.empty?
end

def discard_stale_stamp_file
return unless File.file?(stamp_file)

path = recorded_gem_path
File.delete(stamp_file) unless path && File.file?(path)
end
end
10 changes: 6 additions & 4 deletions spec/commands/clean_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ def should_not_have_gems(*gems)
expect(vendored_gems("bin/myrackup")).not_to exist
end

it "removes orphaned gemspecs from ABI-scoped specification dirs", rubygems: ">= 4.1.0.dev" do
it "removes orphaned gemspecs from ABI-scoped specification dirs", rubygems: ">= 4.1.0.a" do
gemfile <<-G
source "https://gem.repo1"

Expand All @@ -412,20 +412,22 @@ def should_not_have_gems(*gems)
should_have_gems "foo-1.0"
end

it "does not remove gemspecs for content-addressed gems in the bundle", :compact_index, rubygems: ">= 4.1.0.dev" do
it "does not remove gemspecs for content-addressed gems in the bundle", :compact_index, rubygems: ">= 4.1.0.a" do
skip "Gem::ContentAddress not available" if ruby_core?
skip "ABI-scoped requirements do not match prerelease Ruby versions" if Gem.ruby_version.prerelease?

simulate_platform "x86_64-linux" do
current_abi = "#{Gem.ruby_version.segments[0]}.#{Gem.ruby_version.segments[1]}"
build_repo2 do
build_gem "mygem", "1.0" do |s|
s.platform = Gem::Platform.new("x86_64-linux")
s.write "lib/mygem.rb", "MYGEM = '1.0 not_content_addressed'"
end
end

build_gem "mygem", "1.0", ruby_abi: Gem.ruby_abi, path: gem_repo2("gems") do |s|
build_gem "mygem", "1.0", content_addressable: true, path: gem_repo2("gems") do |s|
s.platform = Gem::Platform.new("x86_64-linux")
s.required_ruby_version = "~> #{Gem.ruby_abi}.0"
s.required_ruby_version = "~> #{current_abi}.0"
s.write "lib/mygem.rb", "MYGEM = '1.0 content_addressed'"
end

Expand Down
Loading
Loading