From 0983e19592d467e1414aaae1df9d4e4f850e1f80 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Thu, 15 Jan 2026 12:48:03 +0100 Subject: [PATCH] Always install the `bundler` gem in the system gems path: - Fix https://github.com/ruby/rubygems/issues/8106 - ### Problem Bundler autoswitch feature kicks in unexpectedly which ends up creating issue as described in #8106. This patch solves this problem, but will also prevent Bundler from autoswitching most of the time, thanks to picking up the right bundler version from the start. ### Details When `bundle install` is ran, Bundler reads the Gemfile.lock and check the `BUNDLED WITH` section to see if the runtime bundler version matches. If not, Bundler install the version requested by the Gemfile.lock and put that new `bundler` gem inside the `BUNDLE_PATH` configured. Installing the `bundler` gem inside the `BUNDLE_PATH` instead of system gems is the main reason the Bundler autoswitch feature exists. In this patch, I propose that we always install `bundler` inside system gems. By doing this, when a call to `require 'bundler'` is encountered, RubyGems will be able to detect *all* `bundler` version that exists on the user's machine and be able to pick the right bundler version, without doing this kernel exec gymnastic. Right now, if the bundler version specified in the Gemfile.lock is installed outside system gems, then RubyGems will activate the latest bundler version found in system gems, then, when bundler is loaded, Bundler autoswitch kicks in which modifies `GEM_HOME` (with whatever the user configured in `BUNDLE_PATH`) and see that another bundler version matching the Gemfile.lock exists. It then re-exec with `Kernel.exec` and override the `GEM_HOME` env, so that when the script is reexecuted and the `require 'bundler'` is encountered again, RubyGems will search for bundler in the correct folder. --- bundler/lib/bundler.rb | 4 +-- .../lib/bundler/plugin/installer/rubygems.rb | 2 +- bundler/lib/bundler/rubygems_integration.rb | 4 +-- bundler/lib/bundler/self_manager.rb | 12 ------- bundler/lib/bundler/source/rubygems.rb | 12 ++++--- bundler/spec/commands/install_spec.rb | 33 +++++++++++++++++ bundler/spec/commands/update_spec.rb | 6 ++-- bundler/spec/runtime/self_management_spec.rb | 4 +-- bundler/spec/runtime/setup_spec.rb | 36 +++++++++++++++++++ 9 files changed, 86 insertions(+), 27 deletions(-) diff --git a/bundler/lib/bundler.rb b/bundler/lib/bundler.rb index 51ea3beeb0f7..11966f8ca850 100644 --- a/bundler/lib/bundler.rb +++ b/bundler/lib/bundler.rb @@ -452,13 +452,13 @@ def default_bundle_dir SharedHelpers.default_bundle_dir end - def system_bindir + def system_bindir(install_dir = nil) # Gem.bindir doesn't always return the location that RubyGems will install # system binaries. If you put '-n foo' in your .gemrc, RubyGems will # install binstubs there instead. Unfortunately, RubyGems doesn't expose # that directory at all, so rather than parse .gemrc ourselves, we allow # the directory to be set as well, via `bundle config set --local bindir foo`. - Bundler.settings[:system_bindir] || Bundler.rubygems.gem_bindir + Bundler.settings[:system_bindir] || Bundler.rubygems.gem_bindir(install_dir) end def preferred_gemfile_name diff --git a/bundler/lib/bundler/plugin/installer/rubygems.rb b/bundler/lib/bundler/plugin/installer/rubygems.rb index cb5db9c30eb2..4bd0358f74b0 100644 --- a/bundler/lib/bundler/plugin/installer/rubygems.rb +++ b/bundler/lib/bundler/plugin/installer/rubygems.rb @@ -6,7 +6,7 @@ class Installer class Rubygems < Bundler::Source::Rubygems private - def rubygems_dir + def rubygems_dir(_) Plugin.root end diff --git a/bundler/lib/bundler/rubygems_integration.rb b/bundler/lib/bundler/rubygems_integration.rb index e04ef232592a..b5863b96ccd4 100644 --- a/bundler/lib/bundler/rubygems_integration.rb +++ b/bundler/lib/bundler/rubygems_integration.rb @@ -81,8 +81,8 @@ def gem_dir Gem.dir end - def gem_bindir - Gem.bindir + def gem_bindir(install_dir = nil) + Gem.bindir(install_dir || gem_dir) end def user_home diff --git a/bundler/lib/bundler/self_manager.rb b/bundler/lib/bundler/self_manager.rb index 1db77fd46b3f..10eec03151f6 100644 --- a/bundler/lib/bundler/self_manager.rb +++ b/bundler/lib/bundler/self_manager.rb @@ -67,11 +67,6 @@ def install(spec) end def restart_with(version) - configured_gem_home = ENV["GEM_HOME"] - configured_orig_gem_home = ENV["BUNDLER_ORIG_GEM_HOME"] - configured_gem_path = ENV["GEM_PATH"] - configured_orig_gem_path = ENV["BUNDLER_ORIG_GEM_PATH"] - argv0 = File.exist?($PROGRAM_NAME) ? $PROGRAM_NAME : Process.argv0 cmd = [argv0, *ARGV] cmd.unshift(Gem.ruby) unless File.executable?(argv0) @@ -79,10 +74,6 @@ def restart_with(version) Bundler.with_original_env do Kernel.exec( { - "GEM_HOME" => configured_gem_home, - "BUNDLER_ORIG_GEM_HOME" => configured_orig_gem_home, - "GEM_PATH" => configured_gem_path, - "BUNDLER_ORIG_GEM_PATH" => configured_orig_gem_path, "BUNDLER_VERSION" => version.to_s, }, *cmd @@ -132,7 +123,6 @@ def remote_specs end def find_latest_matching_spec(requirement) - Bundler.configure local_result = find_latest_matching_spec_from_collection(local_specs, requirement) return local_result if local_result && requirement.specific? @@ -163,8 +153,6 @@ def ruby_can_restart_with_same_arguments? end def installed?(restart_version) - Bundler.configure - Bundler.rubygems.find_bundler(restart_version.to_s) end diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index e1e030ffc899..1802aae96de1 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -173,8 +173,8 @@ def install(spec, options = {}) return if Bundler.settings[:no_install] - install_path = rubygems_dir - bin_path = Bundler.system_bindir + install_path = rubygems_dir(spec) + bin_path = Bundler.system_bindir(install_path) require_relative "../rubygems_gem_installer" @@ -432,7 +432,7 @@ def fetch_gem_if_possible(spec, previous_spec = nil) def fetch_gem(spec, previous_spec = nil) spec.fetch_platform - cache_path = download_cache_path(spec) || default_cache_path_for(rubygems_dir) + cache_path = download_cache_path(spec) || default_cache_path_for(rubygems_dir(spec)) gem_path = package_path(cache_path, spec) return gem_path if File.exist?(gem_path) @@ -448,8 +448,10 @@ def installed?(spec) installed_specs[spec].any? && !spec.installation_missing? end - def rubygems_dir - Bundler.bundle_path + def rubygems_dir(spec) + dir = Pathname(ENV["BUNDLER_ORIG_GEM_HOME"]) if spec.name == "bundler" + + dir ||= Bundler.bundle_path end def default_cache_path_for(dir) diff --git a/bundler/spec/commands/install_spec.rb b/bundler/spec/commands/install_spec.rb index ae651bf981c7..26ffa5b3c618 100644 --- a/bundler/spec/commands/install_spec.rb +++ b/bundler/spec/commands/install_spec.rb @@ -801,6 +801,39 @@ end end + describe "Bundler version in Gemfile.lock" do + it "always download the bundler gem to system path" do + gemfile <<~G + source "https://gem.repo4" + G + + lockfile <<~L + GEM + remote: https://gem.repo4/ + specs: + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + + BUNDLED WITH + 2.7.99 + L + + build_repo4 do + build_gem "bundler", "2.7.99" + end + + bundle :install, env: { "BUNDLE_PATH" => "vendor/bundle" } + + expect(system_gem_path("gems/bundler-2.7.99")).to exist + expect(system_gem_path("bin/bundler")).to exist + expect(vendored_gems("gems/bundler-2.7.99")).to_not exist + expect(vendored_gems("bin/bundler")).to_not exist + end + end + describe "Ruby version in Gemfile.lock" do context "and using an unsupported Ruby version" do it "prints an error" do diff --git a/bundler/spec/commands/update_spec.rb b/bundler/spec/commands/update_spec.rb index cdaeb75c4a33..f1e5b4bf715a 100644 --- a/bundler/spec/commands/update_spec.rb +++ b/bundler/spec/commands/update_spec.rb @@ -1700,7 +1700,7 @@ gem "myrack" G - system_gems "bundler-9.0.0.dev", path: local_gem_path + system_gems "bundler-9.0.0.dev" bundle :update, bundler: "9.0.0.dev", verbose: "true" checksums = checksums_section_when_enabled do |c| @@ -1737,7 +1737,7 @@ source "https://gem.repo4" gem "myrack" G - system_gems "bundler-9.0.0", path: local_gem_path + system_gems "bundler-9.0.0" bundle :update, bundler: "9.0.0", verbose: true expect(out).not_to include("Fetching gem metadata from https://rubygems.org/") @@ -1787,7 +1787,7 @@ 9.0.0 L - system_gems "bundler-9.9.9", path: local_gem_path + system_gems "bundler-9.9.9" bundle "update --bundler=9.9.9", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false expect(err).to include("An update to the version of Bundler itself was requested, but the lockfile can't be updated because frozen mode is set") diff --git a/bundler/spec/runtime/self_management_spec.rb b/bundler/spec/runtime/self_management_spec.rb index fbffd2dca2df..2ed67b2572be 100644 --- a/bundler/spec/runtime/self_management_spec.rb +++ b/bundler/spec/runtime/self_management_spec.rb @@ -70,7 +70,7 @@ bundle "config set --local path vendor/bundle" bundle "install" expect(out).to include("Bundler #{current_version} is running, but your lockfile was generated with #{previous_minor}. Installing Bundler #{previous_minor} and restarting using that version.") - expect(vendored_gems("gems/bundler-#{previous_minor}")).to exist + expect(system_gem_path("gems/bundler-#{previous_minor}")).to exist # It does not uninstall the locked bundler bundle "clean" @@ -111,7 +111,7 @@ bundle "config set --local deployment true" bundle "install" expect(out).to include("Bundler #{current_version} is running, but your lockfile was generated with #{previous_minor}. Installing Bundler #{previous_minor} and restarting using that version.") - expect(vendored_gems("gems/bundler-#{previous_minor}")).to exist + expect(system_gem_path("gems/bundler-#{previous_minor}")).to exist # It does not uninstall the locked bundler bundle "clean" diff --git a/bundler/spec/runtime/setup_spec.rb b/bundler/spec/runtime/setup_spec.rb index 1ffaffef0ed2..09f49058bc9d 100644 --- a/bundler/spec/runtime/setup_spec.rb +++ b/bundler/spec/runtime/setup_spec.rb @@ -1218,6 +1218,42 @@ def lock_with(bundler_version = nil) end end + context "auto switch" do + it "picks the right bundler version without re-exec" do + bundle "config unset path.system" + bundle "config set --local path #{bundled_app(".bundle")}" + + build_repo4 do + build_bundler "4.4.99" + build_gem "myrack", "1.0.0" + end + + lockfile(lock_with("4.4.99")) + + install_gemfile <<-G + source "https://gem.repo4" + gem "myrack" + G + + # ruby-core test setup has always "lib" in $LOAD_PATH so `require "bundler/setup"` always activate the local version rather than using RubyGems gem activation stuff + unless ruby_core? + file = bundled_app("bin/bundle_version.rb") + create_file file, <<~RUBY + #!#{Gem.ruby} + p 'executed once' + require 'bundler/setup' + p Bundler::VERSION + RUBY + + file.chmod(0o777) + cmd = Gem.win_platform? ? "#{Gem.ruby} bin/bundle_version.rb" : "bin/bundle_version.rb" + in_bundled_app cmd + + expect(out).to eq(%("executed once"\n"4.4.99")) + end + end + end + context "is newer" do it "does not change the lock or warn" do lockfile lock_with(Bundler::VERSION.succ)