[ruby/rubygems] Fix dependency source bug in bundler

I stumbled across a bundler bug that had me scratching my head for
awhile, because I hadn't experienced it before.

In some cases when changing the source in a gemfile from a
`Source::Gemspec` to either a `Source::Path` or `Source::Git` only the
parent gem will have it's gem replaced and updated and the child
components will retain the original version. This only happens if the gem
version of the `Source::Gemspec` and `Source::Git` are the same. It also
requires another gem to share a dependency with the one being updated.

For example if I have the following gemfile:

```
gem "rails", "~> 8.1.1"
gem "propshaft"
```

Rails has a component called `actionpack` which `propshaft` depends on.

If I change `rails` to point at a git source (or path source), only the
path for `rails` gets updated:

```
gem "rails", github: "rails/rails", branch: "8-1-stable"
gem "propshaft"
```

Because `actionpack` is a dependency of `propshaft`, it will remain in
the rubygems source in the lock file WHILE the other gems are correctly
pointing to the git source.

Gemfile.lock:

```
GIT
  remote: https://github.com/rails/rails.git
  revision: https://github.com/ruby/rubygems/commit/9439f463e0ef
  branch: 8-1-stable
  specs:
    actioncable (8.1.1)
      ...
    actionmailbox (8.1.1)
      ...
    actionmailer (8.1.1)
      ...
    actiontext (8.1.1)
      ...
    activejob (8.1.1)
      ...
    activemodel (8.1.1)
      ...
    activerecord (8.1.1)
      ...
    activestorage (8.1.1)
      ...
    rails (8.1.1)
      ...
    railties (8.1.1)
      ...

GEM
  remote: https://rubygems.org/
  specs:
    action_text-trix (2.1.15)
      railties
    actionpack (8.1.1) <===== incorrectly left in Rubygems source
      ...
```

The gemfile will contain `actionpack` in the rubygems source, but will
be missing in the git source so the path will be incorrect. A bundle
show on Rails will point to the correct place:

```
$ bundle show rails
/Users/eileencodes/.gem/ruby/3.4.4/bundler/gems/rails-9439f463e0ef
```

but a bundle show on actionpack will be incorrect:

```
$ bundle show actionpack
/Users/eileencodes/.gem/ruby/3.4.4/gems/actionpack-8.1.1
```

This bug requires the following to reproduce:

1) A gem like Rails that contains components that are released as their
own standalone gem is added to the gemfile pointing to rubygems
2) A second gem is added that depends on one of the gems in the first
gem (like propshaft does on actionpack)
3) The Rails gem is updated to use a git source, pointing to the same
version that is being used by rubygems (ie 8.1.1)
4) `bundle` will only update the path for Rails component gems if no
other gem depends on it.

This incorrectly leaves Rails (or any gem like it) using two different
codepaths / gem source code.

https://github.com/ruby/rubygems/commit/dff76ba4f6
This commit is contained in:
eileencodes 2025-12-19 11:27:28 -05:00 committed by git
parent 25c72b0e8e
commit e1087c1226
2 changed files with 132 additions and 1 deletions

View File

@ -1066,7 +1066,22 @@ module Bundler
deps << dep if !replacement_source || lockfile_source.include?(replacement_source) || new_deps.include?(dep)
else
replacement_source = sources.get(lockfile_source)
parent_dep = @dependencies.find do |d|
next unless d.source && d.source != lockfile_source
next if d.source.is_a?(Source::Gemspec)
parent_locked_specs = @originally_locked_specs[d.name]
parent_locked_specs.any? do |parent_spec|
parent_spec.runtime_dependencies.any? {|rd| rd.name == s.name }
end
end
if parent_dep
replacement_source = parent_dep.source
else
replacement_source = sources.get(lockfile_source)
end
end
# Replace the locked dependency's source with the equivalent source from the Gemfile

View File

@ -1079,4 +1079,120 @@ RSpec.describe "bundle install with gems on multiple sources" do
expect(lockfile).to eq original_lockfile.gsub("bigdecimal (1.0.0)", "bigdecimal (3.3.1)")
end
end
context "when switching a gem with components from rubygems to git source" do
before do
build_repo2 do
build_gem "rails", "7.0.0" do |s|
s.add_dependency "actionpack", "7.0.0"
s.add_dependency "activerecord", "7.0.0"
end
build_gem "actionpack", "7.0.0"
build_gem "activerecord", "7.0.0"
# propshaft also depends on actionpack, creating the conflict
build_gem "propshaft", "1.0.0" do |s|
s.add_dependency "actionpack", ">= 7.0.0"
end
end
build_git "rails", "7.0.0", path: lib_path("rails") do |s|
s.add_dependency "actionpack", "7.0.0"
s.add_dependency "activerecord", "7.0.0"
end
build_git "actionpack", "7.0.0", path: lib_path("rails")
build_git "activerecord", "7.0.0", path: lib_path("rails")
install_gemfile <<-G
source "https://gem.repo2"
gem "rails", "7.0.0"
gem "propshaft"
G
end
it "moves component gems to the git source in the lockfile" do
expect(lockfile).to include("remote: https://gem.repo2")
expect(lockfile).to include("rails (7.0.0)")
expect(lockfile).to include("actionpack (7.0.0)")
expect(lockfile).to include("activerecord (7.0.0)")
expect(lockfile).to include("propshaft (1.0.0)")
gemfile <<-G
source "https://gem.repo2"
gem "rails", git: "#{lib_path("rails")}"
gem "propshaft"
G
bundle "install"
expect(lockfile).to include("remote: #{lib_path("rails")}")
expect(lockfile).to include("rails (7.0.0)")
expect(lockfile).to include("actionpack (7.0.0)")
expect(lockfile).to include("activerecord (7.0.0)")
# Component gems should NOT remain in the GEM section
# Extract just the GEM section by splitting on GIT first, then GEM
gem_section = lockfile.split("GEM\n").last.split(/\n(PLATFORMS|DEPENDENCIES)/)[0]
expect(gem_section).not_to include("actionpack (7.0.0)")
expect(gem_section).not_to include("activerecord (7.0.0)")
end
end
context "when switching a gem with components from rubygems to path source" do
before do
build_repo2 do
build_gem "rails", "7.0.0" do |s|
s.add_dependency "actionpack", "7.0.0"
s.add_dependency "activerecord", "7.0.0"
end
build_gem "actionpack", "7.0.0"
build_gem "activerecord", "7.0.0"
# propshaft also depends on actionpack, creating the conflict
build_gem "propshaft", "1.0.0" do |s|
s.add_dependency "actionpack", ">= 7.0.0"
end
end
build_lib "rails", "7.0.0", path: lib_path("rails") do |s|
s.add_dependency "actionpack", "7.0.0"
s.add_dependency "activerecord", "7.0.0"
end
build_lib "actionpack", "7.0.0", path: lib_path("rails")
build_lib "activerecord", "7.0.0", path: lib_path("rails")
install_gemfile <<-G
source "https://gem.repo2"
gem "rails", "7.0.0"
gem "propshaft"
G
end
it "moves component gems to the path source in the lockfile" do
expect(lockfile).to include("remote: https://gem.repo2")
expect(lockfile).to include("rails (7.0.0)")
expect(lockfile).to include("actionpack (7.0.0)")
expect(lockfile).to include("activerecord (7.0.0)")
expect(lockfile).to include("propshaft (1.0.0)")
gemfile <<-G
source "https://gem.repo2"
gem "rails", path: "#{lib_path("rails")}"
gem "propshaft"
G
bundle "install"
expect(lockfile).to include("remote: #{lib_path("rails")}")
expect(lockfile).to include("rails (7.0.0)")
expect(lockfile).to include("actionpack (7.0.0)")
expect(lockfile).to include("activerecord (7.0.0)")
# Component gems should NOT remain in the GEM section
# Extract just the GEM section by splitting appropriately
gem_section = lockfile.split("GEM\n").last.split(/\n(PLATFORMS|DEPENDENCIES)/)[0]
expect(gem_section).not_to include("actionpack (7.0.0)")
expect(gem_section).not_to include("activerecord (7.0.0)")
end
end
end