From 3585271ae3d80db9be39ff72cf43d1b976e11632 Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Wed, 20 May 2026 16:31:10 -0700 Subject: [PATCH 1/2] Add a test demonstrating current behavior with platform specific variant --- .../install/gemfile/specific_platform_spec.rb | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spec/install/gemfile/specific_platform_spec.rb b/spec/install/gemfile/specific_platform_spec.rb index 97b1d233bfaf..96b4c7a98ab6 100644 --- a/spec/install/gemfile/specific_platform_spec.rb +++ b/spec/install/gemfile/specific_platform_spec.rb @@ -261,6 +261,50 @@ expect(the_bundle).not_to include_gem("nokogiri 1.18.10 x86_64-linux") end end + + it "installs the ruby variant but Bundler.setup still complains when only an incompatible platform-specific variant is locked" do + build_repo4 do + build_gem "nokogiri", "1.18.10" + build_gem "nokogiri", "1.18.10" do |s| + s.platform = "x86_64-linux" + s.required_ruby_version = "< #{Gem.ruby_version}" + end + end + + gemfile <<~G + source "https://gem.repo4" + + gem "nokogiri" + G + + lockfile <<-L + GEM + remote: https://gem.repo4/ + specs: + nokogiri (1.18.10-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + nokogiri + + BUNDLED WITH + #{Bundler::VERSION} + L + + simulate_platform "x86_64-linux" do + bundle "install --verbose", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false + expect(exitstatus).to eq(0) + expect(out).to include("Fetching nokogiri 1.18.10\n") + expect(out).to include("Installing nokogiri 1.18.10\n") + + # FIXME: We should not install an alternative and then refuse to use it. + ruby "require 'bundler'; Bundler.setup", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false + expect(exitstatus).not_to eq(0) + expect(err).to include("Could not find nokogiri-1.18.10-x86_64-linux in locally installed gems") + end + end end it "doesn't discard previously installed platform specific gem and fall back to ruby on subsequent bundles" do From 5d97916645e5ced02ea234977233c092cb41d4d8 Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Wed, 20 May 2026 17:36:58 -0700 Subject: [PATCH 2/2] bundler: Lock ruby fallback variants for platform gems This fixes two issues: If a lockfile has a platform variant only bundler will install the ruby variant but then fail at setup time because it only looks for the platform version. The solution to that is to keep ruby platform variants in the lockfile even if ruby is not (or cannot) be added to the PLATFORMS. Now you will get an error if you need the ruby variant but it isn't in the lockfile, and you can actually resolve the issue by putting the ruby variant in the lockfile! --- bundler/lib/bundler/lazy_specification.rb | 21 +-- bundler/lib/bundler/materialization.rb | 10 ++ bundler/lib/bundler/resolver.rb | 6 +- spec/commands/lock_spec.rb | 4 + spec/install/gemfile/platform_spec.rb | 4 + .../install/gemfile/specific_platform_spec.rb | 123 ++++++++++++++++-- spec/lock/lockfile_spec.rb | 2 + spec/resolver/platform_spec.rb | 16 +-- 8 files changed, 161 insertions(+), 25 deletions(-) diff --git a/bundler/lib/bundler/lazy_specification.rb b/bundler/lib/bundler/lazy_specification.rb index 0da621d21fb5..86b2da45169a 100644 --- a/bundler/lib/bundler/lazy_specification.rb +++ b/bundler/lib/bundler/lazy_specification.rb @@ -9,7 +9,8 @@ class LazySpecification include ForcePlatform attr_reader :name, :version, :platform, :materialization - attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version, :overrides + attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version + attr_accessor :overrides, :locked_platforms # # For backwards compatibility with existing lockfiles, if the most specific @@ -49,6 +50,7 @@ def initialize(name, version, platform, source = nil, **materialization_options) @force_ruby_platform = default_force_ruby_platform @most_specific_locked_platform = nil @materialization = nil + @locked_platforms = nil end def missing? @@ -145,7 +147,7 @@ def materialize_for_installation # Exact spec is incompatible; in frozen mode, try to find a compatible platform variant # In non-frozen mode, return nil to trigger re-resolution and lockfile update if Bundler.frozen_bundle? - materialize([name, version]) {|specs| resolve_best_platform(specs) } + materialize([name, version]) {|specs| resolve_best_platform(specs, locked_platforms_only: true) } end else materialize([name, version]) {|specs| resolve_best_platform(specs) } @@ -186,12 +188,12 @@ def use_exact_resolved_specifications? # Try platforms in order of preference until finding a compatible spec. # 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) - find_compatible_platform_spec(specs) || frozen_bundle_fallback(specs) + def resolve_best_platform(specs, locked_platforms_only: false) + find_compatible_platform_spec(specs, locked_platforms_only: locked_platforms_only) || frozen_bundle_fallback(specs) end - def find_compatible_platform_spec(specs) - candidate_platforms.each do |plat| + def find_compatible_platform_spec(specs, locked_platforms_only: false) + candidate_platforms(locked_platforms_only: locked_platforms_only).each do |plat| candidates = MatchPlatform.select_best_platform_match(specs, plat) spec = choose_compatible(candidates, fallback_to_non_installable: false) return spec if spec @@ -201,9 +203,12 @@ def find_compatible_platform_spec(specs) # Platforms to try in order of preference. Ruby platform is last since it # requires compilation, but works when precompiled gems are incompatible. - def candidate_platforms + def candidate_platforms(locked_platforms_only: false) target = source.is_a?(Source::Path) ? platform : Bundler.local_platform - [target, platform, Gem::Platform::RUBY].uniq + platforms = [target, platform, Gem::Platform::RUBY].uniq + return platforms unless locked_platforms_only && locked_platforms + + platforms & locked_platforms end # In frozen mode, accept any candidate. Will error at install time. diff --git a/bundler/lib/bundler/materialization.rb b/bundler/lib/bundler/materialization.rb index 82e48464a73b..d73e9124a823 100644 --- a/bundler/lib/bundler/materialization.rb +++ b/bundler/lib/bundler/materialization.rb @@ -12,6 +12,7 @@ def initialize(dep, platform, candidates:) @dep = dep @platform = platform @candidates = candidates + set_locked_platforms end def complete? @@ -55,5 +56,14 @@ def incomplete_specs private attr_reader :dep, :platform + + def set_locked_platforms + return unless @candidates + + platforms = @candidates.map(&:platform) + @candidates.each do |candidate| + candidate.locked_platforms = platforms if candidate.respond_to?(:locked_platforms=) + end + end end end diff --git a/bundler/lib/bundler/resolver.rb b/bundler/lib/bundler/resolver.rb index 096a34924916..ef71fb141142 100644 --- a/bundler/lib/bundler/resolver.rb +++ b/bundler/lib/bundler/resolver.rb @@ -302,11 +302,15 @@ def all_versions_for(package) next groups if package.force_ruby_platform? end - platform_group = Resolver::SpecGroup.new(platform_specs.flatten.uniq) + platform_specs = platform_specs.flatten.uniq + platform_group = Resolver::SpecGroup.new((platform_specs + ruby_specs).uniq) next groups if platform_group == ruby_group groups << Resolver::Candidate.new(version, group: platform_group, priority: 1) + platform_only_group = Resolver::SpecGroup.new(platform_specs) + groups << Resolver::Candidate.new(version, group: platform_only_group, priority: 0) unless platform_only_group == platform_group + groups end end diff --git a/spec/commands/lock_spec.rb b/spec/commands/lock_spec.rb index 8ab3cc7e8dd6..1a434009232e 100644 --- a/spec/commands/lock_spec.rb +++ b/spec/commands/lock_spec.rb @@ -1083,8 +1083,10 @@ simulate_platform("x86-mingw32") { bundle :lock } checksums = checksums_section_when_enabled do |c| + c.checksum gem_repo4, "ffi", "1.9.14" c.checksum gem_repo4, "ffi", "1.9.14", "x86-mingw32" c.checksum gem_repo4, "gssapi", "1.2.0" + c.checksum gem_repo4, "mixlib-shellout", "2.2.6" c.checksum gem_repo4, "mixlib-shellout", "2.2.6", "universal-mingw32" c.checksum gem_repo4, "win32-process", "0.8.3" end @@ -1093,9 +1095,11 @@ GEM remote: https://gem.repo4/ specs: + ffi (1.9.14) ffi (1.9.14-x86-mingw32) gssapi (1.2.0) ffi (>= 1.0.1) + mixlib-shellout (2.2.6) mixlib-shellout (2.2.6-universal-mingw32) win32-process (~> 0.8.2) win32-process (0.8.3) diff --git a/spec/install/gemfile/platform_spec.rb b/spec/install/gemfile/platform_spec.rb index e12933ebcfb9..c28af3d4abfe 100644 --- a/spec/install/gemfile/platform_spec.rb +++ b/spec/install/gemfile/platform_spec.rb @@ -208,6 +208,7 @@ c.checksum gem_repo4, "empyrean", "0.1.0" c.checksum gem_repo4, "ffi", "1.9.23", "java" c.checksum gem_repo4, "method_source", "0.9.0" + c.checksum gem_repo4, "pry", "0.11.3" c.checksum gem_repo4, "pry", "0.11.3", "java" c.checksum gem_repo4, "spoon", "0.0.6" end @@ -220,6 +221,9 @@ empyrean (0.1.0) ffi (1.9.23-java) method_source (0.9.0) + pry (0.11.3) + coderay (~> 1.1.0) + method_source (~> 0.9.0) pry (0.11.3-java) coderay (~> 1.1.0) method_source (~> 0.9.0) diff --git a/spec/install/gemfile/specific_platform_spec.rb b/spec/install/gemfile/specific_platform_spec.rb index 96b4c7a98ab6..eeda764c39f5 100644 --- a/spec/install/gemfile/specific_platform_spec.rb +++ b/spec/install/gemfile/specific_platform_spec.rb @@ -262,7 +262,7 @@ end end - it "installs the ruby variant but Bundler.setup still complains when only an incompatible platform-specific variant is locked" do + it "adds and installs the ruby variant when only an incompatible platform-specific variant was locked" do build_repo4 do build_gem "nokogiri", "1.18.10" build_gem "nokogiri", "1.18.10" do |s| @@ -294,15 +294,64 @@ L simulate_platform "x86_64-linux" do - bundle "install --verbose", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false - expect(exitstatus).to eq(0) - expect(out).to include("Fetching nokogiri 1.18.10\n") - expect(out).to include("Installing nokogiri 1.18.10\n") + bundle "install --verbose" + expect(out).to include("Installing nokogiri 1.18.10") + expect(the_bundle).to include_gem("nokogiri 1.18.10") + end + + expect(lockfile).to eq <<~L + GEM + remote: https://gem.repo4/ + specs: + nokogiri (1.18.10) + nokogiri (1.18.10-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + nokogiri + + BUNDLED WITH + #{Bundler::VERSION} + L + end + + it "fails at install time when only an incompatible platform-specific variant is locked" do + build_repo4 do + build_gem "nokogiri", "1.18.10" + build_gem "nokogiri", "1.18.10" do |s| + s.platform = "x86_64-linux" + s.required_ruby_version = "< #{Gem.ruby_version}" + end + end + + gemfile <<~G + source "https://gem.repo4" + + gem "nokogiri" + G + + lockfile <<-L + GEM + remote: https://gem.repo4/ + specs: + nokogiri (1.18.10-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + nokogiri + + BUNDLED WITH + #{Bundler::VERSION} + L - # FIXME: We should not install an alternative and then refuse to use it. - ruby "require 'bundler'; Bundler.setup", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false + simulate_platform "x86_64-linux" do + bundle "install --verbose", env: { "BUNDLE_FROZEN" => "true" }, raise_on_error: false expect(exitstatus).not_to eq(0) - expect(err).to include("Could not find nokogiri-1.18.10-x86_64-linux in locally installed gems") + expect(err).to include("nokogiri-1.18.10-x86_64-linux requires ruby version < #{Gem.ruby_version}") end end end @@ -810,10 +859,13 @@ bundle "update --conservative nokogiri" end + checksums.checksum gem_repo4, "nokogiri", "1.13.0" + expect(lockfile).to eq <<~L GEM remote: https://gem.repo4/ specs: + nokogiri (1.13.0) nokogiri (1.13.0-x86_64-darwin) sorbet-static (0.5.10601-x86_64-darwin) @@ -829,6 +881,61 @@ L end + it "locks ruby fallback variant dependencies without adding the ruby platform" do + build_repo4 do + build_gem "native_tool", "1.0" do |s| + s.add_dependency "rake" + end + + build_gem "native_tool", "1.0" do |s| + s.platform = "x86_64-linux" + end + + build_gem "rake" + + build_gem "sorbet-static", "0.5.10601" do |s| + s.platform = "x86_64-linux" + end + end + + simulate_platform "x86_64-linux" do + install_gemfile <<~G + source "https://gem.repo4" + + gem "native_tool" + gem "sorbet-static" + G + end + + checksums = checksums_section_when_enabled do |c| + c.checksum gem_repo4, "native_tool", "1.0" + c.checksum gem_repo4, "native_tool", "1.0", "x86_64-linux" + c.checksum gem_repo4, "rake", "1.0" + c.checksum gem_repo4, "sorbet-static", "0.5.10601", "x86_64-linux" + end + + expect(lockfile).to eq <<~L + GEM + remote: https://gem.repo4/ + specs: + native_tool (1.0) + rake + native_tool (1.0-x86_64-linux) + rake (1.0) + sorbet-static (0.5.10601-x86_64-linux) + + PLATFORMS + x86_64-linux + + DEPENDENCIES + native_tool + sorbet-static + #{checksums} + BUNDLED WITH + #{Bundler::VERSION} + L + end + it "automatically fixes the lockfile if only ruby platform is locked and some gem has no ruby variant available" do build_repo4 do build_gem("sorbet-static-and-runtime", "0.5.10160") do |s| diff --git a/spec/lock/lockfile_spec.rb b/spec/lock/lockfile_spec.rb index 654ac02aa7d4..0a2aa8aca852 100644 --- a/spec/lock/lockfile_spec.rb +++ b/spec/lock/lockfile_spec.rb @@ -1324,6 +1324,7 @@ G checksums = checksums_section_when_enabled do |c| + c.checksum gem_repo2, "platform_specific", "1.0" c.checksum gem_repo2, "platform_specific", "1.0", "universal-java-16" end @@ -1331,6 +1332,7 @@ GEM remote: https://gem.repo2/ specs: + platform_specific (1.0) platform_specific (1.0-universal-java-16) PLATFORMS diff --git a/spec/resolver/platform_spec.rb b/spec/resolver/platform_spec.rb index a1d095d024de..83bec610a6dc 100644 --- a/spec/resolver/platform_spec.rb +++ b/spec/resolver/platform_spec.rb @@ -71,7 +71,7 @@ should_resolve_as %w[foo-1.0.0] end - it "prefers the platform specific gem to the ruby version" do + it "prefers the platform specific gem to the ruby version, but keeps the ruby fallback" do @index = build_index do gem "foo", "1.0.0" gem "foo", "1.0.0", "x64-mingw-ucrt" @@ -79,7 +79,7 @@ dep "foo" platforms "x64-mingw-ucrt" - should_resolve_as %w[foo-1.0.0-x64-mingw-ucrt] + should_resolve_as %w[foo-1.0.0 foo-1.0.0-x64-mingw-ucrt] end describe "on a linux platform" do @@ -88,7 +88,7 @@ # Gem's platform is *-linux => gem is glibc + maybe musl compatible # Gem's platform is *-linux-musl => gem is musl compatible but not glibc - it "favors the platform version-specific gem on a version-specifying linux platform" do + it "favors the platform version-specific gem on a version-specifying linux platform, but keeps the ruby fallback" do @index = build_index do gem "foo", "1.0.0" gem "foo", "1.0.0", "x86_64-linux" @@ -97,10 +97,10 @@ dep "foo" platforms "x86_64-linux-musl" - should_resolve_as %w[foo-1.0.0-x86_64-linux-musl] + should_resolve_as %w[foo-1.0.0 foo-1.0.0-x86_64-linux-musl] end - it "favors the version-less gem over the version-specific gem on a gnu linux platform" do + it "favors the version-less gem over the version-specific gem on a gnu linux platform, but keeps the ruby fallback" do @index = build_index do gem "foo", "1.0.0" gem "foo", "1.0.0", "x86_64-linux" @@ -109,7 +109,7 @@ dep "foo" platforms "x86_64-linux" - should_resolve_as %w[foo-1.0.0-x86_64-linux] + should_resolve_as %w[foo-1.0.0 foo-1.0.0-x86_64-linux] end it "ignores the platform version-specific gem on a gnu linux platform" do @@ -122,7 +122,7 @@ should_not_resolve end - it "falls back to the platform version-less gem on a linux platform with a version" do + it "falls back to the platform version-less gem on a linux platform with a version, but keeps the ruby fallback" do @index = build_index do gem "foo", "1.0.0" gem "foo", "1.0.0", "x86_64-linux" @@ -130,7 +130,7 @@ dep "foo" platforms "x86_64-linux-musl" - should_resolve_as %w[foo-1.0.0-x86_64-linux] + should_resolve_as %w[foo-1.0.0 foo-1.0.0-x86_64-linux] end it "falls back to the ruby platform gem on a gnu linux platform when only a version-specifying gem is available" do