Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d5de690
[ruby/rubygems] Support content addressable gems in gem build
OughtPuts Aug 28, 2026
59a15d6
[ruby/rubygems] Support content addressable gems in gem list, search,…
girachawda Aug 30, 2026
fc3cbd5
[ruby/rubygems] Support content addressable gems in gem install
OughtPuts Aug 30, 2026
a6d0982
[ruby/rubygems] Support content addressable gems in gem push
girachawda Aug 28, 2026
48a51b4
[ruby/rubygems] Support content addressable gems in gem yank
girachawda Aug 28, 2026
79a0cf7
[ruby/rubygems] Support content addressable gems in bundle install, l…
OughtPuts Aug 28, 2026
910a95c
[ruby/rubygems] Vendor compact_index under a dedicated namespace
jenshenny Aug 30, 2026
e9c4de7
[ruby/rubygems] Support content addressable gems in gem update, outda…
girachawda Aug 31, 2026
2534623
[ruby/rubygems] Scope CA plugin stubs by Ruby ABI
girachawda Sep 1, 2026
fc21d37
[ruby/rubygems] Move content addresses to a separate CONTENT ADDRESSE…
jenshenny Sep 1, 2026
3127391
[ruby/rubygems] Centralize content addressing semantics in Gem::Conte…
jenshenny Sep 2, 2026
37a5674
[ruby/rubygems] Install content-addressable gemspecs under specificat…
jenshenny Sep 3, 2026
9219df4
[ruby/rubygems] Use CompactIndexV2API in CompactIndexCooldownBadCreat…
jenshenny Sep 4, 2026
0d43a23
[ruby/rubygems] Support ABI-scoped specification dirs in gem doctor
jenshenny Sep 3, 2026
6dc55fa
[ruby/rubygems] Support ABI-scoped specification dirs in bundle clean
jenshenny Sep 3, 2026
b295817
[ruby/rubygems] Include ABI-scoped dirs in gem contents --spec-dir ou…
jenshenny Sep 3, 2026
7a095da
[ruby/rubygems] Update compact index platform field format
jenshenny Sep 6, 2026
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
65 changes: 40 additions & 25 deletions lib/bundler/checksum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,83 +187,98 @@ def inspect
# However, if the new checksum is from a different source, we register like normal.
# This ensures a mismatch error where there are multiple top level sources
# that contain the same gem with different checksums.
# The store is keyed by full name rather than lock name because a
# content-addressable build and the ordinary platform build of the same
# name, version, and platform share a lock name while being different
# files with different checksums.
def replace(spec, checksum)
return unless checksum

lock_name = spec.lock_name
full_name = spec.full_name
@store_mutex.synchronize do
existing = fetch_checksum(lock_name, checksum.algo)
existing = fetch_checksum(full_name, checksum.algo)
if !existing || existing.same_source?(checksum)
store_checksum(lock_name, checksum)
store_checksum(full_name, checksum)
else
merge_checksum(lock_name, checksum, existing)
merge_checksum(full_name, checksum, existing, spec.lock_name)
end
end
end

def missing?(spec)
@store[spec.lock_name].nil?
@store[spec.full_name].nil?
end

def empty?(spec)
return false unless spec.source.is_a?(Bundler::Source::Rubygems)

@store[spec.lock_name].empty?
@store[spec.full_name].empty?
end

def register(spec, checksum)
register_checksum(spec.lock_name, checksum)
register_checksum(spec.full_name, checksum, spec.lock_name)
end

def merge!(other)
other.store.each do |lock_name, checksums|
other.store.each do |full_name, checksums|
checksums.each do |_algo, checksum|
register_checksum(lock_name, checksum)
register_checksum(full_name, checksum)
end
end
end

def to_lock(spec)
lock_name = spec.lock_name
checksums = @store[lock_name]
if checksums&.any?
"#{lock_name} #{checksums.values.map(&:to_lock).sort.join(",")}"
checksums = checksums_to_lock(platform_full_name(spec))
if checksums
"#{lock_name} #{checksums}"
else
lock_name
end
end

def checksums_to_lock(full_name)
checksums = @store[full_name]
return unless checksums&.any?

checksums.values.map(&:to_lock).sort.join(",")
end

private

def register_checksum(lock_name, checksum)
def platform_full_name(spec)
Gem::NameTuple.new(spec.name, spec.version, spec.platform).full_name
end

def register_checksum(full_name, checksum, display_name = full_name)
@store_mutex.synchronize do
if checksum
existing = fetch_checksum(lock_name, checksum.algo)
existing = fetch_checksum(full_name, checksum.algo)
if existing
merge_checksum(lock_name, checksum, existing)
merge_checksum(full_name, checksum, existing, display_name)
else
store_checksum(lock_name, checksum)
store_checksum(full_name, checksum)
end
else
init_checksum(lock_name)
init_checksum(full_name)
end
end
end

def merge_checksum(lock_name, checksum, existing)
existing.merge!(checksum) || raise(ChecksumMismatchError.new(lock_name, existing, checksum))
def merge_checksum(full_name, checksum, existing, display_name = full_name)
existing.merge!(checksum) || raise(ChecksumMismatchError.new(display_name, existing, checksum))
end

def store_checksum(lock_name, checksum)
init_checksum(lock_name)[checksum.algo] = checksum
def store_checksum(full_name, checksum)
init_checksum(full_name)[checksum.algo] = checksum
end

def init_checksum(lock_name)
@store[lock_name] ||= {}
def init_checksum(full_name)
@store[full_name] ||= {}
end

def fetch_checksum(lock_name, algo)
@store[lock_name]&.fetch(algo, nil)
def fetch_checksum(full_name, algo)
@store[full_name]&.fetch(algo, nil)
end
end
end
Expand Down
30 changes: 25 additions & 5 deletions lib/bundler/endpoint_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,33 @@ module Bundler
class EndpointSpecification < Gem::Specification
include MatchRemoteMetadata

attr_reader :name, :version, :platform, :checksum, :created_at
attr_reader :name, :version, :platform, :checksum, :created_at, :content_address
attr_writer :dependencies
attr_accessor :remote, :locked_platform

def initialize(name, version, platform, spec_fetcher, dependencies, metadata = nil)
def initialize(name, version, suffix, spec_fetcher, dependencies, metadata = nil)
super()
@name = name
@version = Gem::Version.create version
@platform = Gem::Platform.new(platform)
@spec_fetcher = spec_fetcher
@dependencies = nil
@unbuilt_dependencies = dependencies
@content_address = nil
@required_platform = nil

@loaded_from = nil
@remote_specification = nil
@locked_platform = nil

parse_metadata(metadata)

if Gem::ContentAddress.content_addressed_row?(suffix, @required_platform, @required_ruby_version)
@content_address = suffix
@platform = @required_platform
@required_rubygems_version ||= Gem::Requirement.default
else
@platform = Gem::Platform.new(suffix)
end
end

def insecurely_materialized?
Expand Down Expand Up @@ -147,11 +156,13 @@ def inspect
private

def _remote_specification
@_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, @platform])
suffix = @content_address || @platform
@_remote_specification ||= @spec_fetcher.fetch_spec([@name, @version, suffix])
end

def local_specification_path
"#{base_dir}/specifications/#{full_name}.gemspec"
File.join(Gem::SpecificationRecord.specification_dir_for(self, base_dir),
"#{full_name}.gemspec")
end

def parse_metadata(data)
Expand Down Expand Up @@ -183,6 +194,8 @@ def parse_metadata(data)
@required_ruby_version = Gem::Requirement.new(v)
when "created_at"
@created_at = parse_created_at(v.is_a?(Array) ? v.last : v)&.freeze
when "platform"
@required_platform = required_platform_from(Array(v).last)
end
end
rescue StandardError => e
Expand Down Expand Up @@ -215,5 +228,12 @@ def parse_created_at(value)
def build_dependency(name, requirements)
Dependency.new(name, requirements)
end

def required_platform_from(value)
value = value.to_s
return if value.empty?

Gem::Platform.new(value)
end
end
end
6 changes: 3 additions & 3 deletions lib/bundler/fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,13 @@ def specs_with_retry(gem_names, source)
def specs(gem_names, source)
index = Bundler::Index.new

fetch_specs(gem_names).each do |name, version, platform, dependencies, metadata|
fetch_specs(gem_names).each do |name, version, suffix, dependencies, metadata|
spec = if dependencies
EndpointSpecification.new(name, version, platform, self, dependencies, metadata).tap do |es|
EndpointSpecification.new(name, version, suffix, self, dependencies, metadata).tap do |es|
source.checksum_store.replace(es, es.checksum)
end
else
RemoteSpecification.new(name, version, platform, self)
RemoteSpecification.new(name, version, suffix, self)
end
spec.source = source
spec.remote = @remote
Expand Down
25 changes: 20 additions & 5 deletions lib/bundler/lazy_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class LazySpecification
include MatchPlatform
include ForcePlatform

attr_reader :name, :version, :platform, :materialization
attr_reader :name, :version, :platform, :materialization, :content_address
attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version
attr_accessor :overrides

Expand All @@ -27,21 +27,22 @@ class LazySpecification
alias_method :runtime_dependencies, :dependencies

def self.from_spec(s)
lazy_spec = new(s.name, s.version, s.platform, s.source)
lazy_spec = new(s.name, s.version, s.platform, s.source, content_address: s.content_address)
lazy_spec.dependencies = s.runtime_dependencies
lazy_spec.required_ruby_version = s.required_ruby_version
lazy_spec.required_rubygems_version = s.required_rubygems_version
lazy_spec.overrides = s.overrides if s.is_a?(LazySpecification)
lazy_spec
end

def initialize(name, version, platform, source = nil, **materialization_options)
def initialize(name, version, platform, source = nil, content_address: nil, **materialization_options)
@name = name
@version = version
@dependencies = []
@required_ruby_version = Gem::Requirement.default
@required_rubygems_version = Gem::Requirement.default
@platform = platform || Gem::Platform::RUBY
@content_address = content_address

@original_source = source
@source = source
Expand All @@ -65,7 +66,9 @@ def source_changed?
end

def full_name
@full_name ||= if platform == Gem::Platform::RUBY
@full_name ||= if Gem::ContentAddress.content_addressed?(self, validate_ruby_abi: false)
"#{@name}-#{@version}-#{@content_address}"
elsif platform == Gem::Platform::RUBY
"#{@name}-#{@version}"
else
"#{@name}-#{@version}-#{platform}"
Expand All @@ -81,7 +84,7 @@ def lock_name
end

def name_tuple
Gem::NameTuple.new(@name, @version, @platform)
Gem::NameTuple.new(@name, @version, @platform, content_address: @content_address)
end

def ==(other)
Expand Down Expand Up @@ -116,6 +119,16 @@ def satisfies?(dependency)
@name == dependency.name && effective_requirement.satisfied_by?(Gem::Version.new(@version))
end

##
# Assigns the content address parsed from the lockfile's CONTENT
# ADDRESSES section. The full name embeds the content address, so its
# memoization must be invalidated.

def content_address=(value)
@content_address = value
@full_name = nil
end

def to_lock
out = String.new
out << " #{lock_name}\n"
Expand Down Expand Up @@ -192,6 +205,8 @@ def use_exact_resolved_specifications?
# Used for legacy lockfiles and as a fallback when the exact locked spec
# is incompatible. Falls back to frozen bundle behavior if none match.
def resolve_best_platform(specs, locked_platforms: nil)
specs = MatchPlatform.select_all_content_address_match(specs, content_address)

find_compatible_platform_spec(specs, locked_platforms: locked_platforms) || frozen_bundle_fallback(specs)
end

Expand Down
26 changes: 24 additions & 2 deletions lib/bundler/lockfile_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def generate!
add_sources
add_platforms
add_dependencies
add_content_addresses
add_checksums
add_locked_ruby_version
add_bundled_with
Expand Down Expand Up @@ -66,10 +67,31 @@ def add_dependencies
end
end

def add_content_addresses
content_addresses = definition.resolve.filter_map do |spec|
next unless Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)

line = "#{spec.lock_name} #{spec.content_address}"

if definition.locked_checksums
checksums = spec.source.checksum_store.checksums_to_lock(spec.full_name)
line += " #{checksums}" if checksums
end

line
end

add_section("CONTENT ADDRESSES", content_addresses) unless content_addresses.empty?
end

def add_checksums
return unless definition.locked_checksums
checksums = definition.resolve.map do |spec|
spec.source.checksum_store.to_lock(spec)
checksums = definition.resolve.filter_map do |spec|
line = spec.source.checksum_store.to_lock(spec)

next if line == spec.lock_name && Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)

line
end

add_section("CHECKSUMS", checksums + bundler_checksum)
Expand Down
Loading