From a42ded1d99230809bcb19947ec5a3f307e0855ef Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 13:44:22 +0900 Subject: [PATCH 1/5] Don't reassign Gem::TSort when old RubyGems already defines it RubyGems older than 3.6 has no rubygems/vendored_tsort, and its own Gem::TSort is often already loaded through rubygems/request_set by the time Bundler runs, e.g. from Gem.activate_bin_path in binstubs. The unconditional reassignment printed an already-initialized-constant warning to stderr, breaking specs that assert empty stderr against system RubyGems. The old vendored Gem::TSort is API-compatible, so keep it when present. Co-Authored-By: Claude Fable 5 --- lib/bundler/vendored_tsort.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/bundler/vendored_tsort.rb b/lib/bundler/vendored_tsort.rb index 8d8ab26ecef9..e49492994d8f 100644 --- a/lib/bundler/vendored_tsort.rb +++ b/lib/bundler/vendored_tsort.rb @@ -4,5 +4,8 @@ require "rubygems/vendored_tsort" rescue LoadError require "tsort" - Gem::TSort = TSort + # RubyGems older than 3.6 has no rubygems/vendored_tsort, but may have + # already loaded its own API-compatible Gem::TSort through + # rubygems/request_set, e.g. from Gem.activate_bin_path in binstubs. + Gem::TSort = TSort unless defined?(Gem::TSort) end From 40bb5bcd38d972c307b4f20c936997c0da4358d8 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 13:59:28 +0900 Subject: [PATCH 2/5] Restore system RubyGems coverage in CI with explicit RGV=system Since RGV started defaulting to ".", the system-rubygems-bundler job has been silently testing the repo's RubyGems instead of the one shipped with each Ruby, because switch_rubygems is injected into every spec-spawned process. Add an explicit RGV=system mode and set it on the job's test step. The harness itself keeps running on the checked out RubyGems even in this mode. The flattened lib/ contains both Bundler and RubyGems, so it is unavoidably on the harness load path and would get mixed into an older RubyGems otherwise. Only processes spawned by specs boot system RubyGems untouched, which is the configuration under test. Co-Authored-By: Claude Fable 5 --- .github/workflows/system-rubygems-bundler.yml | 2 ++ doc/GETTING_STARTED.md | 8 ++++++-- spec/support/helpers.rb | 8 +++++++- spec/support/switch_rubygems.rb | 10 +++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/system-rubygems-bundler.yml b/.github/workflows/system-rubygems-bundler.yml index a9cafcc5a289..e6de96a32124 100644 --- a/.github/workflows/system-rubygems-bundler.yml +++ b/.github/workflows/system-rubygems-bundler.yml @@ -50,6 +50,8 @@ jobs: - name: Run Test run: | bin/parallel_rspec + env: + RGV: system - name: Save system RubyGems version to ENV run: | RGV=$(ruby -e 'puts Gem::VERSION.split(".")[0..2].join(".")') diff --git a/doc/GETTING_STARTED.md b/doc/GETTING_STARTED.md index a290ecd56f27..2585588fee0c 100644 --- a/doc/GETTING_STARTED.md +++ b/doc/GETTING_STARTED.md @@ -73,14 +73,18 @@ For realworld higher level specs (also run in CI): ## Developing Bundler and RubyGems Together -When developing Bundler features or bug fixes that require changes in RubyGems, you can set the `RGV` environment variable to point to the repository root so Bundler's test suite picks up those changes: +Bundler's test suite runs against the RubyGems version selected by the `RGV` environment variable. It defaults to `.`, the RubyGems in this repository, so no setup is needed for the usual case of developing Bundler and RubyGems together: - RGV=.. bin/parallel_rspec + bin/parallel_rspec You can also test against specific RubyGems versions: RGV=v3.2.33 bin/parallel_rspec +Or against the RubyGems copy shipped with your Ruby, as the system-rubygems-bundler CI job does: + + RGV=system bin/parallel_rspec + It's recommended to set this variable permanently using [direnv](https://direnv.net) for consistent development. ## Code Style and Quality diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index b0d4b5008ba7..7059742ec883 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -185,7 +185,13 @@ def gembin(cmd, options = {}) def sys_exec(cmd, options = {}, &block) env = options[:env] || {} - env["RUBYOPT"] = opt_add(opt_add("-r#{spec_dir}/support/switch_rubygems.rb", env["RUBYOPT"]), ENV["RUBYOPT"]) + if ENV["RGV"] == "system" + # Spawned processes must boot system RubyGems untouched, so don't make + # them switch, and drop the load path override the harness runs with. + env["RUBYOPT"] = opt_add(env["RUBYOPT"] || "", opt_remove("-I#{source_lib_dir}", ENV["RUBYOPT"])) + else + env["RUBYOPT"] = opt_add(opt_add("-r#{spec_dir}/support/switch_rubygems.rb", env["RUBYOPT"]), ENV["RUBYOPT"]) + end options[:env] = env sh(cmd, options, &block) diff --git a/spec/support/switch_rubygems.rb b/spec/support/switch_rubygems.rb index 640b9f83b7d1..ac8ba1f03971 100644 --- a/spec/support/switch_rubygems.rb +++ b/spec/support/switch_rubygems.rb @@ -2,4 +2,12 @@ require_relative "rubygems_version_manager" ENV["RGV"] ||= "." -RubygemsVersionManager.new(ENV["RGV"]).switch + +# RGV=system runs specs against system RubyGems: processes spawned by specs +# boot it untouched (see `Spec::Helpers#sys_exec`). The test harness itself +# still runs on the checked out RubyGems, because the repo's lib/ dir is +# unavoidably on its load path and would get mixed into an older RubyGems. +source = ENV["RGV"] +source = "." if source == "system" + +RubygemsVersionManager.new(source).switch From 7300743bfe4362b199c7a5cb808941cf6c7b3bd4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 14:13:37 +0900 Subject: [PATCH 3/5] Fall back to rubygems/tsort instead of activating the tsort default gem RubyGems 3.4 and 3.5 ship vendored tsort under its pre-3.6 name rubygems/tsort, so prefer that when rubygems/vendored_tsort is missing. Requiring the real tsort activated the tsort default gem, breaking the guarantee that bundler/setup activates no gems. This mirrors the fallback chain already used by vendored_net_http. Co-Authored-By: Claude Fable 5 --- lib/bundler/vendored_tsort.rb | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/bundler/vendored_tsort.rb b/lib/bundler/vendored_tsort.rb index e49492994d8f..d7d680344efd 100644 --- a/lib/bundler/vendored_tsort.rb +++ b/lib/bundler/vendored_tsort.rb @@ -1,11 +1,21 @@ # frozen_string_literal: true -begin - require "rubygems/vendored_tsort" -rescue LoadError - require "tsort" - # RubyGems older than 3.6 has no rubygems/vendored_tsort, but may have - # already loaded its own API-compatible Gem::TSort through - # rubygems/request_set, e.g. from Gem.activate_bin_path in binstubs. - Gem::TSort = TSort unless defined?(Gem::TSort) +# The defined? guard avoids reopening Gem::TSort when an old RubyGems has +# already loaded its own copy, e.g. through rubygems/request_set from +# Gem.activate_bin_path in binstubs. +# +unless defined?(Gem::TSort) + begin + require "rubygems/vendored_tsort" + rescue LoadError + begin + # RubyGems 3.4 and 3.5 ship the same file under its pre-3.6 name. + # Requiring the real tsort here instead would activate the tsort + # default gem, and `bundler/setup` must not activate any gems. + require "rubygems/tsort" + rescue LoadError + require "tsort" + Gem::TSort = TSort + end + end end From d3037ba3780fa2af22b68e7a5fb079b9c15c2863 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 15:06:21 +0900 Subject: [PATCH 4/5] Fix bundle install with no_install_plugin on RubyGems older than 4.1 Gem::Installer#remove_stale_plugins only exists since RubyGems 4.1, so installing with no_install_plugin set on an older system RubyGems raised NameError. Reimplement it in Bundler's installer, like generate_plugins already is. Co-Authored-By: Claude Fable 5 --- lib/bundler/rubygems_gem_installer.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index c6313ddf8d0e..d8c50556c531 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -94,6 +94,15 @@ def generate_plugins end end + # Reimplemented from RubyGems, since RubyGems older than 4.1 doesn't + # provide it + def remove_stale_plugins + return unless spec.plugins.empty? + + ensure_writable_dir @plugins_dir + remove_plugins_for(spec, @plugins_dir) + end + def warn_skipped_extensions return if spec.extensions.empty? From 62fa95fe35ce3265855f1b6c8a77860993f5c454 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 15:06:33 +0900 Subject: [PATCH 5/5] Make version-sensitive specs check the RubyGems version under test Under RGV=system the harness runs on the checked out RubyGems while spec-spawned processes run system RubyGems, so rubygems-tagged filters and expectations interpolating Gem::VERSION were checking the wrong version. Capture the system RubyGems version before switching and use it for the rubygems: exclusion filter, version-dependent expectations, and the global gem cache location. The cached git gemspec comparison now serializes with the RubyGems that wrote the file, since Specification#to_ruby output differs across versions. Co-Authored-By: Claude Fable 5 --- spec/commands/exec_spec.rb | 4 ++-- spec/commands/install_spec.rb | 4 ++-- spec/install/gemfile/git_spec.rb | 7 +++++-- spec/install/gems/resolving_spec.rb | 2 +- spec/install/global_cache_spec.rb | 7 ++++--- spec/runtime/self_management_spec.rb | 8 +++----- spec/support/filters.rb | 5 ++++- spec/support/helpers.rb | 7 +++++++ spec/support/switch_rubygems.rb | 7 ++++++- 9 files changed, 34 insertions(+), 17 deletions(-) diff --git a/spec/commands/exec_spec.rb b/spec/commands/exec_spec.rb index d744fc616bf2..e7a294b42932 100644 --- a/spec/commands/exec_spec.rb +++ b/spec/commands/exec_spec.rb @@ -75,13 +75,13 @@ skip "https://github.com/ruby/rubygems/issues/3351" if mswin? install_gemfile "source \"https://gem.repo1\"; gem \"myrack\"" bundle "exec #{gem_cmd} --version" - expect(out).to eq(Gem::VERSION) + expect(out).to eq(exercised_rubygems_version.to_s) end it "works when exec'ing to rubygems through sh -c" do install_gemfile "source \"https://gem.repo1\"; gem \"myrack\"" bundle "exec sh -c '#{gem_cmd} --version'" - expect(out).to eq(Gem::VERSION) + expect(out).to eq(exercised_rubygems_version.to_s) end it "works when exec'ing back to bundler to run a remote resolve" do diff --git a/spec/commands/install_spec.rb b/spec/commands/install_spec.rb index f8a134f23108..1e0e3a7c244b 100644 --- a/spec/commands/install_spec.rb +++ b/spec/commands/install_spec.rb @@ -1394,7 +1394,7 @@ def run expect(gem_make_out).not_to include("make -j8") end - it "uses 3 slots from the available pool when running the compilation of an extension" do + it "uses 3 slots from the available pool when running the compilation of an extension", rubygems: ">= 4.1.0.dev" do ENV.delete("MAKEFLAGS") install_gemfile(<<~G, env: { "BUNDLE_JOBS" => "8" }) @@ -1407,7 +1407,7 @@ def run expect(gem_make_out).to include("make -j3") end - it "consumes 3 slots from the pool when BUNDLE_JOBS isn't set" do + it "consumes 3 slots from the pool when BUNDLE_JOBS isn't set", rubygems: ">= 4.1.0.dev" do ENV.delete("MAKEFLAGS") install_gemfile(<<~G) diff --git a/spec/install/gemfile/git_spec.rb b/spec/install/gemfile/git_spec.rb index ee4c91a4f2b4..5fa740f4e1c6 100644 --- a/spec/install/gemfile/git_spec.rb +++ b/spec/install/gemfile/git_spec.rb @@ -114,9 +114,12 @@ sha = git.ref_for("main", 11) spec_file = default_bundle_path("bundler/gems/foo-1.0-#{sha}/foo.gemspec") expect(spec_file).to exist - ruby_code = Gem::Specification.load(spec_file.to_s).to_ruby + # Serialize with the RubyGems that wrote the file, since `#to_ruby` + # output differs across RubyGems versions + ruby "print Gem::Specification.load(#{spec_file.to_s.dump}).to_ruby" + ruby_code = out file_code = File.read(spec_file) - expect(file_code).to eq(ruby_code) + expect(file_code.strip).to eq(ruby_code) end it "does not update the git source implicitly" do diff --git a/spec/install/gems/resolving_spec.rb b/spec/install/gems/resolving_spec.rb index 111d361aab9c..178f68e30bb6 100644 --- a/spec/install/gems/resolving_spec.rb +++ b/spec/install/gems/resolving_spec.rb @@ -726,7 +726,7 @@ Because every version of require_rubygems depends on RubyGems > 9000 and Gemfile depends on require_rubygems >= 0, RubyGems > 9000 is required. - So, because current RubyGems version is = #{Gem::VERSION}, + So, because current RubyGems version is = #{exercised_rubygems_version}, version solving has failed. E expect(err).to end_with(nice_error) diff --git a/spec/install/global_cache_spec.rb b/spec/install/global_cache_spec.rb index 259e8d57ba2e..5e5494dbe9d3 100644 --- a/spec/install/global_cache_spec.rb +++ b/spec/install/global_cache_spec.rb @@ -9,9 +9,10 @@ let(:source2) { "http://gemserver.example.org" } def cache_base - # Use the unified global gem cache path if available (from RubyGems), - # otherwise fall back to the Bundler-specific cache location - if Gem.respond_to?(:global_gem_cache_path) + # Use the unified global gem cache path if the RubyGems under test + # provides it, otherwise fall back to the Bundler-specific cache + # location that Bundler uses on RubyGems older than 4.0 + if exercised_rubygems_version >= Gem::Version.new("4.0.0.a") Pathname.new(Gem.global_gem_cache_path) else home(".bundle", "cache", "gems") diff --git a/spec/runtime/self_management_spec.rb b/spec/runtime/self_management_spec.rb index 176c2a312177..970a3869367f 100644 --- a/spec/runtime/self_management_spec.rb +++ b/spec/runtime/self_management_spec.rb @@ -171,11 +171,9 @@ expect(out).to eq(previous_minor) end - it "requires the right bundler version from the config and run bundle CLI without re-exec" do - unless Bundler.rubygems.provides?(">= 4.1.0.dev") - skip "This spec can only run when Gem::BundlerVersionFinder.bundler_versions reads bundler configs" - end - + # Can only run when Gem::BundlerVersionFinder.bundler_versions reads + # bundler configs + it "requires the right bundler version from the config and run bundle CLI without re-exec", rubygems: ">= 4.1.0.dev" do lockfile_bundled_with(current_version) bundle_config "version #{previous_minor}" diff --git a/spec/support/filters.rb b/spec/support/filters.rb index 2be25b4a78a6..67a32354d081 100644 --- a/spec/support/filters.rb +++ b/spec/support/filters.rb @@ -23,7 +23,10 @@ def inspect RSpec.configure do |config| config.filter_run_excluding realworld: true - config.filter_run_excluding rubygems: RequirementChecker.against(Gem.rubygems_version) + # Version-gated specs care about the RubyGems that spec-spawned processes + # run, which under RGV=system is older than the one loaded in this process. + exercised_rubygems_version = Gem::Version.new(ENV["BUNDLER_SPEC_SYSTEM_RUBYGEMS_VERSION"] || Gem::VERSION) + config.filter_run_excluding rubygems: RequirementChecker.against(exercised_rubygems_version) config.filter_run_excluding git: RequirementChecker.against(git_version) config.filter_run_excluding ruby_repo: !ENV["GEM_COMMAND"].nil? config.filter_run_excluding no_color_tty: Gem.win_platform? || !ENV["GITHUB_ACTION"].nil? diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 7059742ec883..8798cca57bb5 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -183,6 +183,13 @@ def gembin(cmd, options = {}) sys_exec(cmd.to_s, options) end + # The RubyGems version that processes spawned by specs run. Under + # RGV=system it's the system RubyGems, which is older than the one loaded + # in the current process. + def exercised_rubygems_version + @exercised_rubygems_version ||= Gem::Version.new(ENV["BUNDLER_SPEC_SYSTEM_RUBYGEMS_VERSION"] || Gem::VERSION) + end + def sys_exec(cmd, options = {}, &block) env = options[:env] || {} if ENV["RGV"] == "system" diff --git a/spec/support/switch_rubygems.rb b/spec/support/switch_rubygems.rb index ac8ba1f03971..0958a1e82775 100644 --- a/spec/support/switch_rubygems.rb +++ b/spec/support/switch_rubygems.rb @@ -7,7 +7,12 @@ # boot it untouched (see `Spec::Helpers#sys_exec`). The test harness itself # still runs on the checked out RubyGems, because the repo's lib/ dir is # unavoidably on its load path and would get mixed into an older RubyGems. +# The system RubyGems version is captured here, while it's still the one +# loaded, so version-gated specs can check the version actually under test. source = ENV["RGV"] -source = "." if source == "system" +if source == "system" + ENV["BUNDLER_SPEC_SYSTEM_RUBYGEMS_VERSION"] ||= Gem::VERSION + source = "." +end RubygemsVersionManager.new(source).switch