Skip to content

Add content addressable gems support - #9773

Open
jenshenny wants to merge 16 commits into
ruby:masterfrom
Shopify:feature-branch-ca-changes-rubygems
Open

Add content addressable gems support#9773
jenshenny wants to merge 16 commits into
ruby:masterfrom
Shopify:feature-branch-ca-changes-rubygems

Conversation

@jenshenny

@jenshenny jenshenny commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

#9654

TL;DR

Adds RubyGems and Bundler client support for content-addressable ("skinny") binary gems: one artifact per Ruby ABI, named with a SHA-256 prefix.

nokogiri-1.18.9-x86_64-linux.gem # traditional platform gem
nokogiri-1.18.9-78be552b.gem     # content-addressable gem

The branch covers build, discovery, install, display, yank, lockfiles, caching, and bundle install --local. Existing source and platform gems are unchanged.

Why

"Fat" binary gems contain every supported Ruby ABI and keep growing. Skinny binaries are smaller, but builds for the same gem, version, and platform need distinct filenames. A content-derived suffix gives each artifact a unique identity.

A content address must be 8–64 lowercase hexadecimal characters. RubyGems only treats it as one when the gem also has a non-Ruby platform and constrained required_ruby_version, avoiding false matches with ordinary filenames.

Follow ups

  • Update guides
  • suffix widening

gem build

ref: Shopify/rubygems#171.

User behaviour

  • gem build nokogiri.gemspec --ruby-abi 3.4 builds a skinny gem named nokogiri-1.18.9-78be552b.gem, where the suffix is the SHA-256 digest of the gem contents.
  • Without --ruby-abi, behaviour is unchanged.

Details

  • --ruby-abi validates the ABI format (X.Y), requires a non-Ruby platform to be set, and constrains required_ruby_version: if unset it defaults to ~> X.Y.0; a mismatched existing requirement is rejected.
  • The gem is built in memory and validated against the requested ABI; the passed-in spec is only updated once the build succeeds, so a failed build leaves the spec untouched.
  • The hash is deterministic for a given set of gem contents.

gem install

ref: Shopify/rubygems#172 (local) and #173 (remote).

Local

  • Reads the archive, derives the hash from its filename, verifies it against the file's SHA-256, and preserves both hash and platform. The installed directory and gemspec use the hash. A mismatch fails; reinstalling is idempotent.
  • gem install --local GEMNAME is content-addressable aware.

Every install of a CA gem writes a gemspec stub whose # stub: suffix is the hash (so the name-version-<sha> directory resolves). The real platform rides on a separate # stub-target: line that older RubyGems ignore — backwards compatible, while current RubyGems recover both. File: specifications/mygem-1.0-78be552b.gemspec:

# -*- encoding: utf-8 -*-
# stub: mygem 1.0 78be552b lib
# stub: ext/mygem/extconf.rb
# stub-target: platform=x86_64-linux

Gem::Specification.new do |s|
  s.name = 'mygem'
  s.version = Gem::Version.new '1.0'
end

Remote

  • Decodes the compact-index hash and platform:= metadata separately. Distinct hashes remain distinct candidates.

For example, a server info/nokogiri response with two skinny variants (different Ruby ABIs) plus a platform fallback:

---
1.18.9-78be552b |checksum:…,ruby:~> 3.4.0,platform:= x86_64-linux
1.18.9-a1b2c3d4 |checksum:…,ruby:~> 3.3.0,platform:= x86_64-linux
1.18.9-x86_64-linux |checksum:…

The hash is carried in the version token (1.18.9-78be552b); the real platform and Ruby requirement travel in the platform:= / ruby: metadata. The two hashes stay distinct resolver candidates, and 1.18.9-x86_64-linux is the platform fallback.

  • Prefers a compatible skinny gem; resolution falls back to a traditional platform gem, then a source gem.
  • Downloads the SHA-named archive and the SHA-named gemspec for development dependencies.

gem push

ref: Shopify/rubygems#174.

User behaviour

  • gem push name-*.gem --platform x86_64-linux --ruby-abi 3.4 reads the specs of the SHA-named files and pushes the single matching artifact.
  • No match or multiple matches raise instead of guessing. Passing one exact filename remains unchanged.

Details

  • Adds --platform and --ruby-abi selectors. Given multiple SHA-named files, RubyGems reads each specification and selects the one whose platform and required_ruby_version satisfy both selectors.
  • ruby_matches? does not check platform (a RUBY-platform gem with a matching ~> X.Y.0 can be selected by --ruby-abi); this is documented in the tests rather than special-cased.

gem yank

ref: Shopify/rubygems#176.

User behaviour

  • gem yank mygem -v 1.0.0 --platform x86_64-linux --ruby-abi 3.4 sends gem name, version, platform, and ABI so the server can select one skinny variant.
  • Existing yank behaviour is unchanged without --ruby-abi.

Remote queries (gem list / search / info -r)

ref: Shopify/rubygems#175.

User behaviour

  • Shows the real platform and Ruby ABI, not the hash. Versions and platforms are separated; ABIs for the same version/platform are grouped. Examples:
$ gem list -r mygem        # single CA gem on the server
mygem (1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

$ gem search -r mygem      # same version, different platforms/ABIs
mygem (1.0 Platform: arm64-darwin, Ruby ABI: 3.4
         1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

$ gem search -r mygem      # same version+platform, multiple ABIs grouped
mygem (1.0 Platform: x86_64-linux, Ruby ABI: 3.3, 3.4)

$ gem list -r mygem        # multiple versions, each on its own line
mygem (3.0 Platform: arm64-darwin, Ruby ABI: 3.4
         2.0 Platform: x86_64-linux, Ruby ABI: 3.4
         1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

$ gem list -r mygem        # mixed CA, platform (1.0), and source gems
mygem (3.0 Platform: arm64-darwin, Ruby ABI: 3.4
         2.0 Platform: x86_64-linux, Ruby ABI: 3.3
         1.0 Platform: x86_64-linux)

$ gem info -r mygem        # info uses a Platforms: section, ABIs grouped per platform
mygem (1.0)
    Author: A User
    Homepage: http://example.com

    this is a summary

Platforms:
    x86_64-linux Ruby ABI: 3.3, 3.4
  • Unscoped remote queries skip CA metadata (no platform/ABI rows injected).

gem dependency

ref: Shopify/rubygems#189.

User behaviour

  • gem dependency GEM --remote lists content-addressable gems with their content-addressed full name plus the real platform and Ruby ABI, instead of treating the hash as the platform:
$ gem dependency nokogiri --remote
Gem nokogiri-1.18.9-78be552b (Platform: x86_64-linux, Ruby ABI: 3.4)
  racc ~> 1.4
  • Non-CA gems (source, fat, ordinary platform) are unchanged.

Details

  • fetch_remote_specs runs the detected tuples through Gem::SpecFetcher#decode_content_addressable_tuples, which splits the hash from the real platform via the source, fetches each spec, and carries content_address onto it (dependency_command.rb:69-73).
  • content_address_annotation appends (Platform: <plat>, Ruby ABI: <abi>) only when Gem::ContentAddress.content_addressed?(spec) is true (dependency_command.rb:155-165).

bundle install (lockfile + local cache)

ref: Shopify/rubygems#177 (remote) and #178 (lockfile + local cache).

Remote

  • Separates the compact-index hash from the real platform, includes the hash in candidate identity, selects an ABI-compatible skinny gem, and falls back to platform/source gems when needed.

Lockfile and local cache

  • Writes platform and hash to Gemfile.lock and parses both on the next run.
  • Installs the SHA-named artifact from vendor/cache with bundle install --local. Remote and local paths produce the same installed directory; checksums remain keyed by the platform lock name. A CA gem locks with the hash in the version and the real platform beside it.
    • The content address in the version token identifies the artifact, but says nothing about which platform it targets. The trailing platform is what Bundler uses for platform matching on the next run and to preserve the real platform identity when materializing from vendor/cache
GEM
  remote: https://rubygems.org/
  specs:
    content_addressable_test (0.1.0-a56253e0) arm64-darwin

PLATFORMS
  arm64-darwin

DEPENDENCIES
  content_addressable_test

CHECKSUMS
  content_addressable_test (0.1.0-a56253e0) sha256=a56253e023bdae7b291f44ff21a02dd12f2609418eb60ad22327a7ae5cd4f353

BUNDLED WITH
   4.1.0.dev

@jenshenny
jenshenny force-pushed the feature-branch-ca-changes-rubygems branch 2 times, most recently from 4a5def3 to d849a56 Compare August 10, 2026 18:27
@OughtPuts
OughtPuts force-pushed the feature-branch-ca-changes-rubygems branch 3 times, most recently from ce7e319 to 8d050c3 Compare August 24, 2026 15:53

@tenderlove tenderlove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I didn't review the specs super closely, I trust they are covering useful scenarios.

It looks like we've got a lot of array / hash manipulation going on in this PR. Should we be thinking about making real, named objects?

The direction looks good here IMO


def hash
@set.hash ^ @name.hash ^ @version.hash ^ @platform.hash
@set.hash ^ @name.hash ^ @version.hash ^ @platform.hash ^ @content_address.hash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No impact on this PR, but this is a bad hash and we should fix it upstream.

We should be doing [@set, @name, ...].hash

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed!


def hash # :nodoc:
name.hash ^ version.hash
[name, version, platform, content_address].hash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

end

def spec_platforms(entry, platforms)
platforms = platforms.transform_values(&:uniq)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is because it's now a hash of hashes? What is the structure of platforms?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

platforms comes from line 213 in output_versions. It's a one-level hash

platforms = Hash.new {|h,version| h[version] = [] }

In reality it would look something like this:

  {
    Gem::Version.new("1.0.0") => [
      Gem::Platform::RUBY,
      Gem::Platform.new("x86_64-linux"),
      Gem::Platform.new("x86_64-linux")
    ],
    Gem::Version.new("0.9.0") => [
      Gem::Platform::RUBY
    ]
  }

And then `transform_values(&:uniq) would remove any dups in the values.

Comment thread lib/rubygems/source.rb

Gem::NameTuple.new(name, version, platform || "ruby")
suffix ||= "ruby"
content_address = suffix if Gem::ContentAddress.match?(suffix)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This logic is because we're using this same loop with the CA and non-CA RubyGems endpoints?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yep. The compact-index /versions response uses the same suffix field for both formats, legacy entries contain a platform, while CA entries contain a content-address token. This handles both, so it detects CA suffixes and stores them as content_address. The actual platform is decoded later from the gem’s /info metadata!

Comment thread lib/rubygems/source.rb
platform: platform,
ruby_abi: ruby_abi_from(requirements[:ruby]),
}
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It feels like we should make a real object here. Just spitballing but like:

class GemInfo < Struct.new(:version, :suffix, :platform, :ruby_abi)
  def hash; suffix; end
  def eql?(other); other.version == version && other.suffix == suffix; end
end

Though now that I type this out, it seems very similar to NameTuple? It feels like we could be doing more simple code here with set intersections. e.g. wanted_rows.map { make_obj(_1) } & compact_index_info_rows(name).map { make_object(_1) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@tenderlove have implemented a solution in this commit, interested in your thoughts!

@jenshenny

jenshenny commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Testing

With: https://rubygems.org/gems/content_addressable_test

1.1 gem list -r

$ ruby -I$RG/lib $RG/exe/gem list content_addressable_test -r

*** REMOTE GEMS ***

content_addressable_test (0.1.0 Platform: arm64-darwin, Ruby ABI: 3.3, 4.0
                          0.1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

1.2 gem search -r

$ ruby -I$RG/lib $RG/exe/gem search content_addressable_test -r
*** REMOTE GEMS ***

content_addressable_test (0.1.0 Platform: arm64-darwin, Ruby ABI: 3.3, 4.0
                          0.1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

1.3 gem info -r

$ ruby -I$RG/lib $RG/exe/gem info content_addressable_test -r
   content_addressable_test (0.2.0, 0.1.0)                                                                                          
       Platforms:                                                                                                                   
           0.1.0: arm64-darwin Ruby ABI: 3.3, 4.0, x86_64-linux Ruby ABI: 3.3                                                       
           0.2.0: arm64-darwin Ruby ABI: 3.4                                                                                        
       Author: Jenny Shen                                                                                                           
       License: MIT                                                                                                                 
                                                                                                                                    
       Content-addressable gem test  
2. `gem install` from the remote

2.1 Install, filesystem layout, idempotent reinstall, require

$ export GEM_HOME=$(mktemp -d) GEM_PATH=$GEM_HOME
$ ruby -I$RG/lib $RG/exe/gem install content_addressable_test
Fetching content_addressable_test-0.1.0-a56253e0.gem
Successfully installed content_addressable_test-0.1.0-a56253e0
1 gem installed

$ ls $GEM_HOME/gems $GEM_HOME/specifications
gems:
content_addressable_test-0.1.0-a56253e0
specifications:
content_addressable_test-0.1.0-a56253e0.gemspec

$ grep "stub" $GEM_HOME/specifications/*.gemspec
# stub: content_addressable_test 0.1.0 a56253e0 lib
# stub-target: platform=arm64-darwin

$ ruby -I$RG/lib $RG/exe/gem install content_addressable_test   # reinstall
$ ls $GEM_HOME/gems | grep -c content
1

$ ruby -I$RG/lib -e 'require "ca_test"; puts "require OK"'
require OK

Activation detail:

$ ruby -I$RG/lib -e 'gem "content_addressable_test"; spec = Gem.loaded_specs["content_addressable_test"]; ...'
activated: content_addressable_test-0.1.0-a56253e0, platform=arm64-darwin, ca=a56253e0
load path entry: .../gems/content_addressable_test-0.1.0-a56253e0/lib
3. `bundle install` from the remote + lockfile format
$ printf 'source "https://rubygems.org"\ngem "content_addressable_test"\n' > Gemfile
$ ruby -I$RG/lib $RG/exe/bundle install
(completed)

$ cat Gemfile.lock
GEM
  remote: https://rubygems.org/
  specs:
    content_addressable_test (0.1.0-a56253e0) arm64-darwin

PLATFORMS
  arm64-darwin

DEPENDENCIES
  content_addressable_test

CHECKSUMS
  content_addressable_test (0.1.0-a56253e0) sha256=a56253e023bdae7b291f44ff21a02dd12f2609418eb60ad22327a7ae5cd4f353

BUNDLED WITH
  4.1.0.dev

$ ruby -I$RG/lib $RG/exe/bundle check
The Gemfile's dependencies are satisfied

$ ruby -I$RG/lib $RG/exe/bundle exec ruby -e 'require "ca_test"; puts "bundle exec require OK"'
bundle exec require OK
4. `vendor/cache` and `bundle install --local`
$ ruby -I$RG/lib $RG/exe/bundle cache
Updating files in vendor/cache
  * content_addressable_test-0.1.0-a56253e0.gem

$ ls vendor/cache
content_addressable_test-0.1.0-a56253e0.gem

$ cp Gemfile.lock /tmp/lock.before
$ rm -rf vendor/bundle
$ ruby -I$RG/lib $RG/exe/bundle install --local
Installing content_addressable_test 0.1.0 (arm64-darwin)
Bundle complete! 1 Gemfile dependency, 1 gem now installed.

$ diff /tmp/lock.before Gemfile.lock && echo "lockfile round-trip stable"
lockfile round-trip stable

$ ruby -I$RG/lib $RG/exe/bundle exec ruby -e 'require "ca_test"; puts "local install require OK"'
local install require OK
5. `gem build`

Fixture: a minimal gemspec (content_addressable_test 0.2.0, platform
arm64-darwin) with a single file lib/ca_test.rb whose content differs per
variant so we can identify which artifact loads at runtime.

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi 3.4
  Version: 0.2.0
  File: content_addressable_test-0.2.0-7431c024.gem

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi 3.4   # identical content, rebuild
  File: content_addressable_test-0.2.0-7431c024.gem                                  # deterministic

$ # change lib/ca_test.rb content, build for ABI 3.3:
$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi 3.3
  File: content_addressable_test-0.2.0-2213391a.gem
$ ls *.gem
content_addressable_test-0.2.0-2213391a.gem
content_addressable_test-0.2.0-7431c024.gem

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi invalid
ERROR:  While executing gem ... (Gem::OptionParser::InvalidArgument)
    invalid argument: --ruby-abi invalid: Ruby ABI must be in X.Y format
(exit 1, no file created)

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi 3.4.1
ERROR:  While executing gem ... (Gem::OptionParser::InvalidArgument)
(exit 1 — ABI must be X.Y, not a full version)

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec --ruby-abi 3.4 -o out.gem
ERROR:  While executing gem ... (ArgumentError)
(exit 1, no out.gem — content-addressable gems must use the generated file name)

$ # gemspec with required_ruby_version "~> 3.3.0", building for 3.4:
Cannot build gem for Ruby ABI 3.4 because required_ruby_version is set to ~> 3.3.0. Please set required_ruby_version to "~> 3.4.0".
(exit 1)

$ # gemspec without a platform, building with --ruby-abi 3.4:
Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set
(exit 1)

$ ruby -I$RG/lib $RG/exe/gem build content_addressable_test.gemspec   # no --ruby-abi
  File: content_addressable_test-0.2.0-arm64-darwin.gem               # traditional fat gem

$ # gemspec without platform, no --ruby-abi:
  File: content_addressable_test-0.2.0.gem                            # traditional source gem
6. `gem push` with platform/ABI selectors (against production)

Local directory contained: source, fat, skinny-3.3 (2213391a) and
skinny-3.4 (7431c024) builds of 0.2.0. The three refusal cases upload
nothing; only the final command transmits.

$ ruby -I$RG/lib $RG/exe/gem push content_addressable_test-0.2.0*.gem   # 4 files, no selectors
ERROR:  While executing gem ... (Gem::CommandLineError)
    Too many gem names; please specify only one (Gem::CommandLineError)
exit=1

$ ruby -I$RG/lib $RG/exe/gem push content_addressable_test-0.2.0-*.gem --platform arm64-darwin
ERROR:  While executing gem ... (Gem::CommandLineError)
    Multiple gems matched platform arm64-darwin: content_addressable_test-0.2.0-2213391a.gem, content_addressable_test-0.2.0-7431c024.gem, content_addressable_test-0.2.0-arm64-darwin.gem
Specify --ruby-abi with one of: 3.3, 3.4
To push a gem without a Ruby ABI, pass the exact filename.
exit=1

$ ruby -I$RG/lib $RG/exe/gem push content_addressable_test-0.2.0-*.gem --platform arm64-darwin --ruby-abi 3.9
ERROR:  While executing gem ... (Gem::CommandLineError)
    No gem matched platform arm64-darwin and Ruby ABI 3.9
exit=1

$ ruby -I$RG/lib $RG/exe/gem push content_addressable_test-0.2.0-*.gem --platform arm64-darwin --ruby-abi 3.4
You are already signed in on RubyGems.org.
Pushing gem to https://rubygems.org...
You have enabled multi-factor authentication. Please enter OTP code.
Code:   ******
Successfully registered gem: content_addressable_test (0.2.0-7431c024, Platform: arm64-darwin, Ruby ABI 3.4)
exit=0

Index propagation check (~20s after push):

$ curl -s https://rubygems.org/info/content_addressable_test | grep 0.2.0
0.2.0-7431c024 |checksum:7431c024...aba89b,ruby:~> 3.4.0,rubygems:>= 4.1.0.beta1,platform:= arm64-darwin,created_at:2026-08-30T20:27:18Z
7. Version-level ABI fallback

With 0.2.0 published for ABI 3.4 only, a Ruby 4.0 client must skip the newer
version and install the compatible 0.1.0 build.

$ ruby -I$RG/lib $RG/exe/gem list content_addressable_test -r
content_addressable_test (0.2.0 Platform: arm64-darwin, Ruby ABI: 3.4
                          0.1.0 Platform: arm64-darwin, Ruby ABI: 3.3, 4.0
                          0.1.0 Platform: x86_64-linux, Ruby ABI: 3.3)

$ ruby -I$RG/lib $RG/exe/gem install content_addressable_test   # Ruby 4.0.6
Successfully installed content_addressable_test-0.1.0-a56253e0
$ ls $GEM_HOME/gems
content_addressable_test-0.1.0-a56253e0
8. Fat fallback within a version, and `gem update` to a CA build (Ruby 3.4.7)

0.1.0 has no 3.4 skinny → the pinned install should choose the fat gem;
gem update should then move to the 3.4 skinny 0.2.0.

$ ~/.rubies/ruby-3.4.7/bin/ruby -I$RG/lib $RG/exe/gem install content_addressable_test -v 0.1.0
Successfully installed content_addressable_test-0.1.0-arm64-darwin
1 gem installed

$ ~/.rubies/ruby-3.4.7/bin/ruby -I$RG/lib $RG/exe/gem update content_addressable_test
Updating installed gems
Updating content_addressable_test
Successfully installed content_addressable_test-0.2.0-7431c024

$ ls $GEM_HOME/gems
content_addressable_test-0.1.0-arm64-darwin
content_addressable_test-0.2.0-7431c024

$ ruby -e 'gem "content_addressable_test", "0.2.0"; require "ca_test"; puts CA_TEST_VARIANT'
0.2.0 skinny ruby-3.4 arm64-darwin
9. Cooldown embargo of a freshly published CA gem (Ruby 3.4.7)

0.2.0 was published the same day (created_at 20:27Z), so a 7-day cooldown must
hold it back.

$ ~/.rubies/ruby-3.4.7/bin/ruby -I$RG/lib $RG/exe/gem install content_addressable_test -v 0.1.0
Successfully installed content_addressable_test-0.1.0-arm64-darwin

$ ~/.rubies/ruby-3.4.7/bin/ruby -I$RG/lib $RG/exe/gem update content_addressable_test --cooldown 7
Updating installed gems
Nothing to update
Gems already up-to-date: content_addressable_test
The following gem versions were skipped by the cooldown setting:
  * content_addressable_test 0.2.0 (available in 7 days), resolved 0.1.0 instead

$ ls $GEM_HOME/gems
content_addressable_test-0.1.0-arm64-darwin
10. Backward compatibility: stable client on the same server
$ bundle --version
Bundler version 4.0.16

$ bundle install
Installing content_addressable_test 0.1.0 (arm64-darwin)
Bundle complete! 1 Gemfile dependency, 1 gem now installed.

$ grep "content_addressable_test (" Gemfile.lock
    content_addressable_test (0.1.0-arm64-darwin)
  content_addressable_test (0.1.0-arm64-darwin) sha256=326ceb39...

$ ls vendor/bundle/ruby/*/gems
content_addressable_test-0.1.0-arm64-darwin
11. `gem yank` refusal paths (against production)
$ ruby -I$RG/lib $RG/exe/gem yank content_addressable_test -v 0.2.0 --platform arm64-darwin --ruby-abi 3.9; echo "exit=$?"
You are already signed in on RubyGems.org.
Yanking gem from https://rubygems.org...
The version 0.2.0 (arm64-darwin) (Ruby ABI 3.9) does not exist.
exit=0
13. `gem fetch` and `gem dependency --remote`
$ ruby -I$RG/lib $RG/exe/gem fetch content_addressable_test   # Ruby 4.0
Downloaded content_addressable_test-0.1.0-a56253e0
$ ls *.gem
content_addressable_test-0.1.0-a56253e0.gem
$ ruby -I$RG/lib $RG/exe/gem dependency content_addressable_test --remote
   Gem content_addressable_test-0.1.0-arm64-darwin                                                                                  
                                                                                                                                    
   Gem content_addressable_test-0.1.0-796dad80 (Platform: arm64-darwin Ruby ABI: 3.3)                                               
                                                                                                                                    
   Gem content_addressable_test-0.1.0-fe340269 (Platform: x86_64-linux Ruby ABI: 3.3)                                               
                                                                                                                                    
   Gem content_addressable_test-0.1.0-a56253e0 (Platform: arm64-darwin Ruby ABI: 4.0)                                               
                                                                                                                                    
   Gem content_addressable_test-0.2.0-7431c024 (Platform: arm64-darwin Ruby ABI: 3.4)  

@jenshenny
jenshenny marked this pull request as ready for review August 30, 2026 22:15
Comment thread spec/support/artifice/helpers/compact_index.rb Outdated
@jenshenny
jenshenny force-pushed the feature-branch-ca-changes-rubygems branch 4 times, most recently from 6dc3207 to f854b32 Compare August 30, 2026 23:28
jenshenny added a commit to rubygems/rubygems.org that referenced this pull request Aug 31, 2026
Replace Object#present? (ActiveSupport) with a plain truthy check on
content_address in CompactIndex::GemVersionMethods, so the vendored
lib/compact_index* files no longer rely on Rails being loaded.

content_address is either nil or a non-empty hex string (the Version
model's CONTENT_ADDRESS_FORMAT validation rejects empty/other values,
allow_nil: true), so a truthy check is equivalent to present? here.

Ref: ruby/rubygems#9773 (comment)
@jenshenny

jenshenny commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

It looks like we've got a lot of array / hash manipulation going on in this PR. Should we be thinking about making real, named objects?

Added a follow up issue to explore refactoring away the hashes and arrays #9834 - but it's not a blocker for this initial support as we discussed privately. cc: @tenderlove

@jenshenny
jenshenny force-pushed the feature-branch-ca-changes-rubygems branch 3 times, most recently from 27472f4 to 8b25dd2 Compare August 31, 2026 23:16
@jenshenny

jenshenny commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@hsbt thank you for your eyes on this. Added initial patches of 1, 3, and 4 at the moment. Still working on 2 Shopify#195.

1. Skinny gems do not carry rubygems:>= 4.1.0, so older clients accept them through the local path

The server actually adds the constraint when pushed https://github.com/rubygems/rubygems.org/blob/master/app/models/version.rb#L502 (also see https://rubygems.org/info/content_addressable_test). But that doesn't cover the local path and it would be better to have this during build instead of modifying it in the server. Added the required_ruby_version injection Shopify#193. Though I set this for >=4.1.0.a and not >=4.1.0 so it would use pre-releases.

3. A CA plugin stub loads ABI-specific code on an older Ruby sharing that GEM_HOME

Added a subdirectory for Ruby ABI. When a new version of the gem gets installed, removing and regenerating the plugin includes cleaning up plugins in the Ruby ABI subdirectory as well as root. Shopify#192

4. A lockfile written by 4.1 breaks Bundler 4.0, during the co-publication period

Shopify#194

Right, the proposed format will not break. The one minor detail is for the checksums, if the skinny binary SHA is recorded, a mismatch SHA error will raise since it'll compare with the fat binary SHA when RubyGems is downgraded. I opted for recording the skinny binary SHA in the new section and recording the fat gem SHA in the original checksums section so if RubyGems gets downgraded, clients will seamlessly use the fat binary SHA instead. Let me know what you think about that, maybe it's fine to store the skinny binary sha in checksums section? 🤔

GEM
  specs:
    nokogiri (1.19.4-x86_64-linux)

CONTENT ADDRESSES
  nokogiri (1.19.4-x86_64-linux) 86e5e59f sha256=<CA build's sha>

CHECKSUMS
  nokogiri (1.19.4-x86_64-linux) sha256=<fat gem's sha>

If you have the code-level comments handy, I would like to know so those can be addressed as well!

@hsbt

hsbt commented Sep 2, 2026

Copy link
Copy Markdown
Member

Your checksum split looks right to me. Having the content-addressable build's SHA sit in CONTENT ADDRESSES reads a little oddly, since the section name promises addresses, but a third section existing only to hold one checksum would be heavier than the problem it solves.

Anything an older client can see has to describe the artifact it actually installs, so the split has to land somewhere, and this is the cheaper place. I would keep it as you have it.

@hsbt hsbt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have put the rest of my code-level notes on the lines they affect. Three of them could not be anchored to a line, so they are here.

A failed skinny download caches a different artifact under the skinny name. lib/rubygems/remote_fetcher.rb:156 re-raises when spec.original_platform == spec.platform, but original_platform is a String and platform is a Gem::Platform, so the comparison is always false and the guard never fires. For an ordinary gem that is harmless, because the alternate name it falls through to is the same file. For a content-addressed gem the alternate name is foo-1.0-x86_64-linux.gem, a different artifact, and it is written to cache/foo-1.0-deadbeef.gem. The unless File.exist? guard then prevents any refetch, so a transient 404 pins the wrong file under the content address indefinitely. The file is not part of this PR, so I could not comment on the line.

Not implemented yet. The suffix widening path from the RFC is absent. Gem::ContentAddress::PATTERN accepts 8 to 64 characters, but nothing takes a widened name from the server and nothing removes a locally built default-length copy by comparing checksums. gem build also does not print the platform and Ruby ABI of each gem it produces, or emit the manifest the RFC describes. The query commands do decode.

Test coverage. spec/install/gemfile/content_addressable_spec.rb covers coexistence, a matching skinny, a non-matching skinny, the no-skinny fallback, and the skinny-only CHECKSUMS case. There is no example pinning that a pre-4.1 client never sees a content-addressed row, which is the property the required_rubygems_version injection now provides.

Comment thread lib/rubygems/package.rb Outdated
path = @gem&.path
return unless path

return nil unless Gem::ContentAddress.applicable?(spec)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

content_address verifies the filename's suffix against the file's SHA256 just below and raises on mismatch, but this line returns nil without checking anything. Gem::Installer#assign_content_address then assigns that nil over the address the lockfile or index declared, so a disagreement is treated as absence rather than as an error. See my note on installer.rb:976 for what that costs. Failing closed here would cover both halves.

Comment thread lib/rubygems/installer.rb

private

def assign_content_address

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This assigns @package.content_address over spec.content_address without comparing them. When the package returns nil, which package.rb:265 does silently, full_name reverts from foo-1.0-deadbeef to foo-1.0-x86_64-linux, so gem_dir now points at the platform gem's directory. The strict_rm_rf gem_dir and strict_rm_rf spec.extension_dir in Bundler::RubyGemsGemInstaller#install then remove an existing, legitimate installation, and because the directory the lockfile expects is never created, the install repeats.

Comment thread lib/rubygems/resolver.rb Outdated
# content-addressed candidate built for the running Ruby, then the
# earlier-supplied source.
candidates.min_by do |s|
[Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This orders candidates by platform specificity first and the ABI match second, while MatchPlatform.select_best_platform_match filters by content address and ABI first and sorts by platform afterwards. When the platform build is published at a finer granularity than the skinny one, arm64-darwin-27 against arm64-darwin, gem install picks the platform build and bundle install picks the skinny. The RFC calls this ordering a contract both clients must agree on, and its wording of skinny then fat then source reads as ABI first.

Comment thread lib/bundler/match_platform.rb Outdated
return matching if addressable.empty?

compatible = addressable.select(&:matches_current_metadata?)
compatible.any? ? compatible : non_addressable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When every candidate is content-addressed and none matches the running Ruby, compatible and non_addressable are both empty and this returns []. Gem::Platform.sort_and_filter_best_platform_match always returns at least one element, so callers rely on non-empty in meaning non-empty out. frozen_bundle_fallback reaches choose_compatible([]), candidates.last is nil, and the version is dropped in Resolver::Package#platform_specs, so the user sees Could not find gem rather than a requires-ruby-version diagnostic. non_addressable.any? ? non_addressable : matching restores the contract.

suffix = api_data[:suffix]
required_platform = required_platform_from(api_data.dig(:requirements, :platform))

if Gem::ContentAddress.match?(suffix) && required_platform

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This sets @content_address from the suffix and a platform requirement without consulting the Ruby requirement, so a row lacking ruby: yields a spec that carries an address while content_addressed? is false, and full_name and file_name disagree.

More generally, there are several conditions in the tree for "is this content addressed" and they do not agree. content_addressed? requires applicable?, LazySpecification#full_name uses match? && platform != RUBY, and thirteen call sites test match? alone. Two of my other comments are instances of the same disagreement, so a single predicate would remove the class rather than its members.

private

def plugin_stub_dir_for(spec, plugins_dir)
ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The guard is match?(content_address) but the directory comes from spec.ruby_abi, which is nil unless required_ruby_version is exactly ~> X.Y.0. A content-addressed gem whose requirement is written any other way falls back to the root plugins directory and becomes visible to older RubyGems again. gem build --ruby-abi cannot produce that shape, so it takes a gem from elsewhere, but the fallback is silent where the guarantee is meant to be structural.

end

def ruby_abi_plugin_dir_for(spec, plugins_dir)
ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For a non-content-addressed spec this resolves to the running Ruby's ABI directory, so remove_plugins_for clears the root and the current ABI only and a stub written under a different ABI survives. Install a content-addressed gem under 3.4, then a newer platform build under 4.0, and running under 3.4 loads both plugins/nokogiri_plugin.rb and plugins/3.4/nokogiri_plugin.rb. I confirmed both run. test_load_plugins_loads_latest_non_content_addressed_plugin_after_content_addressed_plugin pins newest-wins within one ABI, and that does not hold across ABIs, which is the shared GEM_HOME case.

Comment thread lib/bundler/lockfile_generator.rb Outdated

def add_content_addresses
content_addresses = definition.resolve.filter_map do |spec|
next unless Gem::ContentAddress.match?(spec.content_address)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This writes a line for any spec whose address matches, using spec.lock_name, while NAME_VERSION_CONTENT_ADDRESS requires a platform. A content-addressed spec on the ruby platform produces nokogiri (1.19.4) 86e5e59f, which the parser does not match, so the address is lost on the next read. LazySpecification#full_name already uses match? && platform != RUBY, and the same condition here would keep the two symmetric.

@hsbt

hsbt commented Sep 4, 2026

Copy link
Copy Markdown
Member

@jenshenny The 19 failing Bundler jobs are not from your changes. The branch dropped spec/support/artifice/helpers/compact_index_cooldown.rb in the vendoring commit and master later added a file that requires it, so the merge CI runs hits a LoadError. A rebase on master should clear them.

I verified the rest locally on the current head and it looks right. Suffix widening is the only RFC item still missing, and beta2 seems fine for it. I think this is good to merge once CI is green, so let me know when it is.

Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
@jenshenny
jenshenny force-pushed the feature-branch-ca-changes-rubygems branch 2 times, most recently from 390b9f8 to f4b5be8 Compare September 4, 2026 04:33
girachawda and others added 15 commits September 5, 2026 18:59
Co-authored-by: Harriet Oughton <harriet.oughton@shopify.com>
Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
Co-authored-by: Gira Chawda <gira.chawda@shopify.com>
Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
…al cache

Co-authored-by: Jenny Shen <jenny.shen@shopify.com>
Gem::Indexer (rubygems-generate_index) loads the V1-only CompactIndex
constants, either from the compact_index gem or from its own embedded
copy, so the artifice's copy of rubygems.org's V2-only implementation
can never share that name safely.

Check in a copy of rubygems.org's lib/compact_index, renamed to the
VendoredCompactIndex namespace, under spec/support/vendor/compact_index,
and load it from the artifice with a plain require. Checking the copy in
(rather than downloading it during the test run) keeps the suite
hermetic and offline, makes the namespace rewrite visible in review, and
lets parallel workers and CI runners that skip the test-deps setup load
it without falling back to the incompatible gem.

Refresh the copy with `rake vendor:compact_index`;
`rake vendor:compact_index_check` fails if the checked-in copy drifts
from the pinned upstream ref.
A content-addressable gem was locked inline as name (version-address)
platform. Bundler 4.0 parses the token after the version as the platform,
gets unknown, materializes name-version-unknown, and fails: it then either
re-resolves and rewrites the lockfile or fails outright under frozen.
Lockfiles are committed and read by many Bundler versions during
co-publication, so the inline form breaks the exact clients the transition
is supposed to protect.

The spec line is now an ordinary platform pin, and the content address moves
to its own section:

    GEM
      specs:
        nokogiri (1.19.4-x86_64-linux)

    CONTENT ADDRESSES
      nokogiri (1.19.4-x86_64-linux) 86e5e59f sha256=<content-addressable build's sha>

    CHECKSUMS
      nokogiri (1.19.4-x86_64-linux) sha256=<platform build's sha>

Verified against the released Bundler 4.0.9:

- unknown unindented sections set @parse_method = nil and are skipped
  silently (lockfile_parser.rb:137)
- Definition#lockfiles_equal? subtracts unknown sections before comparing
  (definition.rb:1196), so a plain 4.0 bundle install does not rewrite the
  lockfile and the section survives; it is only dropped on a genuine 4.0
  re-lock, and restored on the next 4.1 re-lock
- 4.0 classifies a locked spec with a missing or empty CHECKSUMS entry as a
  lockfile change (definition.rb:619-620): a plain install re-resolves and
  frozen mode hard-fails. The platform lock name line must therefore carry a
  real checksum, and it must be the platform build's, since 4.0 attributes
  it to the artifact it installs for that lock name

That last point drives the checksum rules:

- the checksum store is keyed by full name instead of lock name, because a
  content-addressable build and the platform build of the same name,
  version, and platform share a lock name while being different files.
  Store#register accepts anything responding to full_name and lock_name, so
  the parser registers CHECKSUMS entries under the name tuple parsed from
  the line rather than the (possibly content-addressed) spec object
- the content-addressable build's checksum is serialized next to its
  address in CONTENT ADDRESSES, the only place older Bundler never parses,
  and verifies the downloaded gem via the existing install-time registration
- CHECKSUMS carries the platform build's checksum, which the compact index
  supplies for every row the fetcher sees, so it is captured during
  resolution without downloading the platform gem
- when no platform build exists (skinny-only publication), the CHECKSUMS
  line is omitted entirely rather than written bare, so every CHECKSUMS
  line describes an artifact installable by its lock name

The section registers in SECTIONS_BY_VERSION_INTRODUCED under 4.1.0, and
its line format requires a platform, since content addressing only applies
to platformed gems.
…ute every eligibility, naming, lockfile, plugin directory, and spec construction decision through its shared predicates
Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01
Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01
Assisted-By: devx/9c2464e2-74cd-4d36-a6c0-50aef8c2ad01
@jenshenny
jenshenny force-pushed the feature-branch-ca-changes-rubygems branch from 3ffc2fb to f2c0576 Compare September 5, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants