Skip to content

Commit 1d76504

Browse files
authored
Merge pull request #9619 from ruby/claude/recursing-panini-6b5e30
Exempt lockfile versions from cooldown on every resolution path
2 parents 9988dab + 9bb56e5 commit 1d76504

4 files changed

Lines changed: 98 additions & 14 deletions

File tree

bundler/lib/bundler/definition.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1329,7 +1329,7 @@ def unlocked_resolution_base
13291329

13301330
def new_resolution_base(last_resolve:, unlock:)
13311331
new_resolution_platforms = @current_platform_missing ? @new_platforms + [Bundler.local_platform] : @new_platforms
1332-
Resolver::Base.new(source_requirements, expanded_dependencies, last_resolve, @platforms, locked_specs: @originally_locked_specs, unlock: unlock, prerelease: gem_version_promoter.pre?, prefer_local: @prefer_local, new_platforms: new_resolution_platforms, overrides: @overrides)
1332+
Resolver::Base.new(source_requirements, expanded_dependencies, last_resolve, @platforms, locked_specs: @originally_locked_specs, unlock: unlock, prerelease: gem_version_promoter.pre?, prefer_local: @prefer_local, new_platforms: new_resolution_platforms, overrides: @overrides, explicit_unlocks: @explicit_unlocks)
13331333
end
13341334

13351335
def new_resolver(base)

bundler/lib/bundler/resolver.rb

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -456,25 +456,29 @@ def cooldown_hint(specs)
456456
def cooldown_excluded?(spec)
457457
return false unless spec.respond_to?(:created_at) && spec.created_at
458458
return false unless spec.respond_to?(:remote) && spec.remote
459-
return false if pinned_by_lockfile_floor?(spec)
459+
return false if locked_by_lockfile?(spec)
460460
days = spec.remote.effective_cooldown
461461
return false if days.nil? || days <= 0
462462
(cooldown_now - spec.created_at) < (days * 86_400)
463463
end
464464

465-
# A spec sitting exactly at a `>= locked_version` prevent-downgrade floor is
466-
# the version the lockfile currently pins. `bundle update` and `bundle
467-
# outdated` install that floor so resolution never moves a gem backwards.
468-
# Filtering it out for cooldown would then make resolution impossible
469-
# whenever the locked version is itself inside the cooldown window, which is
470-
# exactly what happens to a lockfile written before cooldown was enabled.
471-
# Keep it eligible; gems being explicitly updated carry an exact `=`
472-
# requirement instead and stay subject to the cooldown filter.
473-
def pinned_by_lockfile_floor?(spec)
465+
# A version already written to the lockfile has been adopted, and cooldown
466+
# only governs the adoption of *new* versions, so it must never retract one
467+
# the lockfile already pins. Keying this off the locked specs rather than the
468+
# prevent-downgrade floor matters because that floor is absent on resolutions
469+
# that re-pick a gem from scratch: the auxiliary full update run to compute
470+
# `--update` targets, and the from-scratch retries after a conflict unlocks a
471+
# gem. In those passes the locked version is the only candidate, so filtering
472+
# it out makes an unrelated operation impossible whenever every published
473+
# version matching the requirement sits inside the cooldown window.
474+
#
475+
# Gems named on a `bundle update GEM` command are the exception: the user
476+
# asked to move them, so they stay subject to cooldown and a locked-but-fresh
477+
# release is pushed back to an older one (or fails loudly when none exists).
478+
def locked_by_lockfile?(spec)
474479
return false unless defined?(@base) && @base
475-
requirement = base_requirements[spec.name]
476-
return false unless requirement && !requirement.exact?
477-
requirement.requirements.any? {|op, version| op == ">=" && version == spec.version }
480+
return false if @base.explicitly_unlocked?(spec.name)
481+
@base.locked_specs[spec.name].any? {|locked| locked.version == spec.version }
478482
end
479483

480484
def cooldown_now

bundler/lib/bundler/resolver/base.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Base
99

1010
def initialize(source_requirements, dependencies, base, platforms, options)
1111
@overrides = options.delete(:overrides) || []
12+
@explicit_unlocks = options.delete(:explicit_unlocks) || []
1213
@source_requirements = source_requirements
1314
@locked_specs = options[:locked_specs]
1415

@@ -44,6 +45,14 @@ def get_package(name)
4445
@packages[name]
4546
end
4647

48+
# Gems the user named on a `bundle update GEM` / `bundle lock --update GEM`
49+
# command line. These are the only ones meant to move off their locked
50+
# version, so cooldown keeps applying to them while every other locked gem
51+
# stays exempt.
52+
def explicitly_unlocked?(name)
53+
@explicit_unlocks.include?(name)
54+
end
55+
4756
def base_requirements
4857
@base_requirements ||= build_base_requirements
4958
end

spec/install/cooldown_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,52 @@
107107
build_gem "upgradable", "3.0.0" do |s|
108108
s.date = now - (30 * 86_400)
109109
end
110+
111+
# every published version is inside the cooldown window
112+
build_gem "fresh_gem", "0.3.1" do |s|
113+
s.date = now - (1 * 86_400)
114+
end
115+
build_gem "fresh_gem", "0.3.2" do |s|
116+
s.date = now - (1 * 86_400)
117+
end
110118
end
111119
end
112120

121+
it "keeps a locked all-in-cooldown gem when explicitly updating an unrelated gem" do
122+
# Updating ripe_gem runs an auxiliary full-update resolution to compute its
123+
# target version. That pass carries no prevent-downgrade floor, so without
124+
# exempting locked versions it re-picks fresh_gem from an empty
125+
# cooldown-filtered candidate set (every published 0.3.x is in the window)
126+
# and fails an update that never touched fresh_gem.
127+
gemfile <<-G
128+
source "https://gem.repo3", cooldown: 7
129+
gem "fresh_gem", "~> 0.3"
130+
gem "ripe_gem"
131+
G
132+
133+
lockfile <<-L
134+
GEM
135+
remote: https://gem.repo3/
136+
specs:
137+
fresh_gem (0.3.2)
138+
ripe_gem (1.0.0)
139+
140+
PLATFORMS
141+
#{lockfile_platforms}
142+
143+
DEPENDENCIES
144+
fresh_gem (~> 0.3)
145+
ripe_gem
146+
147+
BUNDLED WITH
148+
#{Bundler::VERSION}
149+
L
150+
151+
bundle "lock --update ripe_gem", artifice: "compact_index_cooldown"
152+
153+
expect(lockfile).to include("fresh_gem (0.3.2)")
154+
end
155+
113156
it "excludes versions within the cooldown window" do
114157
gemfile <<-G
115158
source "https://gem.repo3"
@@ -641,6 +684,34 @@
641684
expect(lockfile).not_to include("ripe_gem (2.0.0)")
642685
end
643686

687+
it "still applies cooldown and downgrades a gem explicitly updated via bundle lock --update" do
688+
gemfile <<-G
689+
source "https://gem.repo3", cooldown: 7
690+
gem "ripe_gem"
691+
G
692+
693+
lockfile <<-L
694+
GEM
695+
remote: https://gem.repo3/
696+
specs:
697+
ripe_gem (2.0.0)
698+
699+
PLATFORMS
700+
#{lockfile_platforms}
701+
702+
DEPENDENCIES
703+
ripe_gem
704+
705+
BUNDLED WITH
706+
#{Bundler::VERSION}
707+
L
708+
709+
bundle "lock --update ripe_gem", artifice: "compact_index_cooldown"
710+
711+
expect(lockfile).to include("ripe_gem (1.0.0)")
712+
expect(lockfile).not_to include("ripe_gem (2.0.0)")
713+
end
714+
644715
it "ignores cooldown and installs the locked version when frozen" do
645716
# Frozen installs read the lockfile instead of resolving, so cooldown has
646717
# no say. A version already locked inside the window must still install.

0 commit comments

Comments
 (0)