From b456b2cdf6de56485c55a2391fcc384a25e9d11d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 5 Jun 2026 06:53:21 +0900 Subject: [PATCH 1/2] Keep the locked version eligible when it falls inside the cooldown window bundle update and bundle outdated install a >= locked_version prevent-downgrade floor, so resolution never moves a gem backwards. The cooldown filter was excluding that same locked version, making resolution impossible whenever the lockfile was written before cooldown was enabled and still pins an in-cooldown release. Exempt the version sitting exactly at the floor; gems updated explicitly carry an exact = requirement and stay subject to cooldown. https://github.com/ruby/rubygems/issues/9598 Co-Authored-By: Claude Opus 4.8 (1M context) --- bundler/lib/bundler/resolver.rb | 16 +++++++ spec/install/cooldown_spec.rb | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/bundler/lib/bundler/resolver.rb b/bundler/lib/bundler/resolver.rb index 422b726980d6..753e9987d5b8 100644 --- a/bundler/lib/bundler/resolver.rb +++ b/bundler/lib/bundler/resolver.rb @@ -456,11 +456,27 @@ def cooldown_hint(specs) def cooldown_excluded?(spec) return false unless spec.respond_to?(:created_at) && spec.created_at return false unless spec.respond_to?(:remote) && spec.remote + return false if pinned_by_lockfile_floor?(spec) days = spec.remote.effective_cooldown return false if days.nil? || days <= 0 (cooldown_now - spec.created_at) < (days * 86_400) end + # A spec sitting exactly at a `>= locked_version` prevent-downgrade floor is + # the version the lockfile currently pins. `bundle update` and `bundle + # outdated` install that floor so resolution never moves a gem backwards. + # Filtering it out for cooldown would then make resolution impossible + # whenever the locked version is itself inside the cooldown window, which is + # exactly what happens to a lockfile written before cooldown was enabled. + # Keep it eligible; gems being explicitly updated carry an exact `=` + # requirement instead and stay subject to the cooldown filter. + def pinned_by_lockfile_floor?(spec) + return false unless defined?(@base) && @base + requirement = base_requirements[spec.name] + return false unless requirement && !requirement.exact? + requirement.requirements.any? {|op, version| op == ">=" && version == spec.version } + end + def cooldown_now @cooldown_now ||= Time.now end diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index b3f57d93ccf5..c4bba0fa1d21 100644 --- a/spec/install/cooldown_spec.rb +++ b/spec/install/cooldown_spec.rb @@ -268,5 +268,90 @@ expect(err).to match(/excluded by the cooldown setting/) expect(err).to match(/--cooldown 0/) end + + it "keeps an in-cooldown locked version on bundle update --all instead of failing" do + # Lockfile written before cooldown was enabled pins the now-in-cooldown + # latest version. A full update must not downgrade below it, and cooldown + # must not filter it out, otherwise resolution becomes impossible (#9598). + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + + it "does not fail bundle outdated when the locked version is in cooldown" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + + # exit 0 means no outdated gems and, crucially, no resolution failure (exit 7) + expect(exitstatus).to eq(0) + end + + it "still applies cooldown and downgrades a gem that is updated explicitly" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end end end From 5deac9f767bc95467059124fd14eeb3b2807d314 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 5 Jun 2026 09:32:46 +0900 Subject: [PATCH 2/2] Cover transitive and upgrade paths for in-cooldown locked versions The previous tests only exercised a top-level locked gem. Add a transitive dependency that resolves only through an in-cooldown version, and a case where a cooldown-eligible version above the locked one still gets picked up, so the full update behavior stays pinned down. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/install/cooldown_spec.rb | 76 +++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/spec/install/cooldown_spec.rb b/spec/install/cooldown_spec.rb index c4bba0fa1d21..bad7b7cf3472 100644 --- a/spec/install/cooldown_spec.rb +++ b/spec/install/cooldown_spec.rb @@ -87,6 +87,26 @@ build_gem "ripe_gem", "2.0.0" do |s| s.date = now - (1 * 86_400) end + + # parent only resolves with the in-cooldown child 2.0.0 + build_gem "child", "1.0.0" do |s| + s.date = now - (30 * 86_400) + end + build_gem "child", "2.0.0" do |s| + s.date = now - (1 * 86_400) + end + build_gem "parent", "1.0.0" do |s| + s.add_dependency "child", ">= 2.0.0" + s.date = now - (30 * 86_400) + end + + # a cooldown-eligible version exists above the in-cooldown locked one + build_gem "upgradable", "2.0.0" do |s| + s.date = now - (1 * 86_400) + end + build_gem "upgradable", "3.0.0" do |s| + s.date = now - (30 * 86_400) + end end end @@ -353,5 +373,61 @@ expect(the_bundle).to include_gems("ripe_gem 1.0.0") end + + it "keeps an in-cooldown transitive dependency on bundle update --all" do + gemfile <<-G + source "https://gem.repo3" + gem "parent" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + child (2.0.0) + parent (1.0.0) + child (>= 2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + parent + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("parent 1.0.0", "child 2.0.0") + end + + it "still upgrades to a cooldown-eligible version above the locked one" do + gemfile <<-G + source "https://gem.repo3" + gem "upgradable" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + upgradable (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + upgradable + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("upgradable 3.0.0") + end end end