From ef4b59e2ac4c5482d36ff2a9424db6a463549674 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 13 May 2026 01:43:32 -0500 Subject: [PATCH 1/4] handle pubspec validation errors gracefully --- pub/lib/dependabot/pub/helpers.rb | 2 +- pub/spec/dependabot/pub/update_checker_spec.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pub/lib/dependabot/pub/helpers.rb b/pub/lib/dependabot/pub/helpers.rb index 567ed617a4d..79640c3eb3d 100644 --- a/pub/lib/dependabot/pub/helpers.rb +++ b/pub/lib/dependabot/pub/helpers.rb @@ -295,7 +295,7 @@ def run_dependency_services(command, stdin_data: nil, &blk) sig { params(stderr: String).returns(T.noreturn) } def raise_error(stderr) - if stderr.include?("Failed parsing lock file") || stderr.include?("Unsupported operation") + if stderr.match?(/Failed parsing lock file|Unsupported operation|Duplicate mapping key|doesn't match expected name/) # rubocop:disable Layout/LineLength raise DependencyFileNotEvaluatable, "dependency_services failed: #{stderr}" elsif stderr.include?("Git error") raise Dependabot::InvalidGitAuthToken, "dependency_services failed: #{stderr}" diff --git a/pub/spec/dependabot/pub/update_checker_spec.rb b/pub/spec/dependabot/pub/update_checker_spec.rb index 649e4055563..3b27ddbafc4 100644 --- a/pub/spec/dependabot/pub/update_checker_spec.rb +++ b/pub/spec/dependabot/pub/update_checker_spec.rb @@ -825,6 +825,24 @@ expect { checker.latest_version }.to raise_error(Dependabot::DependencyFileNotResolvable) end end + + context "when pubspec.yaml has duplicate mapping keys" do + let(:stderr) { "Error on line 39, column 3 of pubspec.yaml: Duplicate mapping key." } + + it "raises the correct error" do + expect { checker.latest_version }.to raise_error(Dependabot::DependencyFileNotEvaluatable) + end + end + + context "when pubspec.yaml name doesn't match expected name" do + let(:stderr) do + "Error on line 1, column 7: \"name\" field doesn't match expected name \"flutter_shortcuts\"." + end + + it "raises the correct error" do + expect { checker.latest_version }.to raise_error(Dependabot::DependencyFileNotEvaluatable) + end + end end context "with a git dependency" do From 9028619fdecf2762b9b1118d0e2d72c35afadffa Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Fri, 22 May 2026 18:29:56 -0500 Subject: [PATCH 2/4] fix cooldown incorrect update in pre-commit --- .../additional_dependency_checkers/base.rb | 10 ++- .../additional_dependency_checkers/dart.rb | 3 +- .../additional_dependency_checkers/go.rb | 3 +- .../additional_dependency_checkers/node.rb | 3 +- .../additional_dependency_checkers/python.rb | 3 +- .../additional_dependency_checkers/ruby.rb | 3 +- .../additional_dependency_checkers/rust.rb | 3 +- .../dependabot/pre_commit/metadata_finder.rb | 2 +- .../dependabot/pre_commit/update_checker.rb | 3 +- .../update_checker/latest_version_finder.rb | 76 +++++++++++++++++++ .../python_spec.rb | 45 +++++++++++ .../latest_version_finder_spec.rb | 48 ++++++++++++ 12 files changed, 192 insertions(+), 10 deletions(-) diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/base.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/base.rb index c2d4549eec4..09ec3405716 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/base.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/base.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "sorbet-runtime" +require "dependabot/package/release_cooldown_options" module Dependabot module PreCommit @@ -43,14 +44,16 @@ class Base source: T::Hash[Symbol, T.untyped], credentials: T::Array[Dependabot::Credential], requirements: T::Array[T::Hash[Symbol, T.untyped]], - current_version: T.nilable(String) + current_version: T.nilable(String), + cooldown_options: T.nilable(Dependabot::Package::ReleaseCooldownOptions) ).void end - def initialize(source:, credentials:, requirements:, current_version:) + def initialize(source:, credentials:, requirements:, current_version:, cooldown_options: nil) @source = source @credentials = credentials @requirements = requirements @current_version = current_version + @cooldown_options = cooldown_options end # Find the latest available version for this dependency @@ -79,6 +82,9 @@ def updated_requirements(latest_version); end sig { returns(T.nilable(String)) } attr_reader :current_version + sig { returns(T.nilable(Dependabot::Package::ReleaseCooldownOptions)) } + attr_reader :cooldown_options + sig { returns(T.nilable(String)) } def package_name source[:package_name]&.to_s diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/dart.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/dart.rb index 02ea810874f..804697fa511 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/dart.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/dart.rb @@ -85,7 +85,8 @@ def build_pub_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/go.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/go.rb index 171098605a5..0f89380a07f 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/go.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/go.rb @@ -89,7 +89,8 @@ def build_go_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/node.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/node.rb index fa9f31c59c5..418b3224229 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/node.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/node.rb @@ -86,7 +86,8 @@ def build_npm_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/python.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/python.rb index 95dd34279a6..2de62d82912 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/python.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/python.rb @@ -92,7 +92,8 @@ def build_pip_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/ruby.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/ruby.rb index 572643c659d..e8ec561d637 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/ruby.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/ruby.rb @@ -86,7 +86,8 @@ def build_bundler_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/rust.rb b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/rust.rb index e5a3f1b44b9..50cc444bfcd 100644 --- a/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/rust.rb +++ b/pre_commit/lib/dependabot/pre_commit/additional_dependency_checkers/rust.rb @@ -87,7 +87,8 @@ def build_cargo_update_checker credentials: credentials, ignored_versions: [], security_advisories: [], - raise_on_ignored: false + raise_on_ignored: false, + update_cooldown: cooldown_options ) end diff --git a/pre_commit/lib/dependabot/pre_commit/metadata_finder.rb b/pre_commit/lib/dependabot/pre_commit/metadata_finder.rb index 098623fd8e1..eabedb671d3 100644 --- a/pre_commit/lib/dependabot/pre_commit/metadata_finder.rb +++ b/pre_commit/lib/dependabot/pre_commit/metadata_finder.rb @@ -20,7 +20,7 @@ def look_up_source if info.nil? dependency.name else - info[:url] || info.fetch("url") + info[:url] || info[:repo_url] || info["url"] || dependency.name end Source.from_url(url) end diff --git a/pre_commit/lib/dependabot/pre_commit/update_checker.rb b/pre_commit/lib/dependabot/pre_commit/update_checker.rb index d8cb4392901..0b850371fab 100644 --- a/pre_commit/lib/dependabot/pre_commit/update_checker.rb +++ b/pre_commit/lib/dependabot/pre_commit/update_checker.rb @@ -349,7 +349,8 @@ def additional_dependency_checker(language, source) source: source, credentials: credentials, requirements: dependency.requirements, - current_version: dependency.version + current_version: dependency.version, + cooldown_options: update_cooldown ) rescue StandardError => e Dependabot.logger.error("Error creating checker for #{language}: #{e.message}") diff --git a/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb b/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb index 0427e95fbe6..ad2ff1a7a49 100644 --- a/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb +++ b/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb @@ -87,6 +87,13 @@ def latest_release_version release = cooldown_filter(release) if release.nil? + Dependabot.logger.info("Latest release is in cooldown, searching for next available version") + fallback = find_latest_version_not_in_cooldown + if fallback + Dependabot.logger.info("Found fallback version not in cooldown: #{fallback}") + return fallback + end + Dependabot.logger.info("Returning current version/ref (no viable filtered release) #{current_version}") return current_version end @@ -160,6 +167,75 @@ def cooldown_filter(release) release end + sig { returns(T.nilable(Dependabot::Version)) } + def find_latest_version_not_in_cooldown + # Only applicable for version-tagged releases, not commit SHA releases + return nil if release_type_sha? + + candidate_tags = sorted_candidate_version_tags + return nil if candidate_tags.empty? + + Dependabot.logger.info("Checking #{candidate_tags.length} older versions for cooldown fallback") + + find_first_tag_not_in_cooldown(candidate_tags) + rescue StandardError => e + Dependabot.logger.error("Error finding fallback version: #{e.message}") + nil + end + + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def sorted_candidate_version_tags + all_tags = @git_helper.git_commit_checker.local_tags_for_allowed_versions_matching_existing_precision + latest_tag = latest_version_tag + latest_version_value = latest_tag&.fetch(:version, nil) + cur_version = current_version + + all_tags + .select { |tag| tag[:version].is_a?(Gem::Version) } + .reject { |tag| latest_version_value && tag[:version] == latest_version_value } + .select { |tag| cur_version.nil? || tag[:version] > cur_version } + .sort_by { |tag| tag[:version] } + .reverse + end + + sig do + params(candidate_tags: T::Array[T::Hash[Symbol, T.untyped]]) + .returns(T.nilable(Dependabot::Version)) + end + def find_first_tag_not_in_cooldown(candidate_tags) + url = @git_helper.git_commit_checker.dependency_source_details&.fetch(:url) + source = T.must(Source.from_url(url)) + + SharedHelpers.in_a_temporary_directory(File.dirname(source.repo)) do |temp_dir| + repo_contents_path = File.join(temp_dir, File.basename(source.repo)) + SharedHelpers.run_shell_command("git clone --bare --no-recurse-submodules #{url} #{repo_contents_path}") + + Dir.chdir(repo_contents_path) do + candidate_tags.each do |tag| + commit_sha = tag[:commit_sha] + next unless commit_sha + + date_str = SharedHelpers.run_shell_command( + "git show --no-patch --format=\"%cd\" --date=iso #{commit_sha}", + fingerprint: "git show --no-patch --format=\"%cd\" --date=iso " + ) + release_date = Time.parse(date_str) + + unless release_in_cooldown_period?(release_date) + Dependabot.logger.info( + "Found version #{tag[:version]} not in cooldown (released #{release_date})" + ) + return T.cast(tag[:version], Dependabot::Version) + end + + Dependabot.logger.info("Version #{tag[:version]} also in cooldown, trying next") + end + end + end + + nil + end + sig { returns(T.nilable(String)) } def commit_metadata_details @commit_metadata_details ||= T.let( diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/python_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/python_spec.rb index e576d98b71e..3080de533fe 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/python_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/python_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" require "dependabot/pre_commit/additional_dependency_checkers/python" require "dependabot/python/update_checker" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Python do let(:checker) do @@ -406,4 +407,48 @@ expect(updated.first[:source][:package_name]).to eq("types-requests") end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + let(:pip_checker) { instance_double(Dependabot::Python::UpdateChecker) } + + it "passes cooldown_options as update_cooldown to the Python UpdateChecker" do + allow(Dependabot::Python::UpdateChecker).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(pip_checker) + allow(pip_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(Dependabot::Python::UpdateChecker).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(Dependabot::Python::UpdateChecker).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(pip_checker) + allow(pip_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(Dependabot::Python::UpdateChecker).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end diff --git a/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb b/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb index b4e29be9b97..a57c94a889e 100644 --- a/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb @@ -192,6 +192,54 @@ end end + context "when latest version is in cooldown" do + let(:update_cooldown) do + Dependabot::Package::ReleaseCooldownOptions.new( + default_days: 7 + ) + end + + before do + # Stub commit_metadata_details to return a recent date (simulating latest in cooldown) + allow_any_instance_of(described_class) # rubocop:disable RSpec/AnyInstance + .to receive(:commit_metadata_details) + .and_return(Time.now.utc.strftime("%Y-%m-%d %H:%M:%S %z")) + end + + it "falls back to a previous version not in cooldown" do + older_tag = { + tag: "v5.0.0", + version: Dependabot::PreCommit::Version.new("5.0.0"), + commit_sha: "abc123" + } + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance + .to receive(:local_tags_for_allowed_versions_matching_existing_precision) + .and_return([older_tag]) + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance + .to receive(:dependency_source_details) + .and_return({ url: "https://github.com/pre-commit/pre-commit-hooks" }) + allow(Dependabot::SharedHelpers).to receive(:in_a_temporary_directory).and_yield("/tmp/fake") + allow(Dir).to receive(:chdir).and_yield + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/git clone --bare/, any_args).and_return("") + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/git show --no-patch/, any_args) + .and_return((Time.now - (30 * 24 * 60 * 60)).utc.strftime("%Y-%m-%d %H:%M:%S %z")) + + result = finder.latest_release_version + expect(result.to_s).to eq("5.0.0") + end + + it "returns current version when no fallback candidates exist" do + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance + .to receive(:local_tags_for_allowed_versions_matching_existing_precision) + .and_return([]) + + result = finder.latest_release_version + expect(result.to_s).to eq("4.4.0") + end + end + context "with nil cooldown" do let(:update_cooldown) { nil } From bde5d36c36be2b11df694777b9ec2ecb00169ec0 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Mon, 25 May 2026 18:42:01 -0500 Subject: [PATCH 3/4] consolidate git clone calls into one and add specs --- .../update_checker/latest_version_finder.rb | 226 ++++++++---------- .../latest_version_finder_spec.rb | 78 +++++- 2 files changed, 164 insertions(+), 140 deletions(-) diff --git a/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb b/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb index ad2ff1a7a49..548e79500d1 100644 --- a/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb +++ b/pre_commit/lib/dependabot/pre_commit/update_checker/latest_version_finder.rb @@ -46,6 +46,7 @@ def initialize( @raise_on_ignored = raise_on_ignored @options = options @cooldown_options = cooldown_options + @cooldown_selected_tag = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped])) @git_helper = T.let(git_helper, Dependabot::PreCommit::Helpers::Githelper) super( @@ -85,29 +86,35 @@ def latest_release_version Dependabot.logger.info("Available release version/ref is #{release}") - release = cooldown_filter(release) - if release.nil? - Dependabot.logger.info("Latest release is in cooldown, searching for next available version") - fallback = find_latest_version_not_in_cooldown - if fallback - Dependabot.logger.info("Found fallback version not in cooldown: #{fallback}") - return fallback - end - - Dependabot.logger.info("Returning current version/ref (no viable filtered release) #{current_version}") - return current_version - end - - release + filter_release_with_cooldown(release) end sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) } def latest_version_tag - available_latest_version_tag + @cooldown_selected_tag || available_latest_version_tag end private + sig do + params(release: T.any(Dependabot::Version, String)) + .returns(T.nilable(T.any(Dependabot::Version, String))) + end + def filter_release_with_cooldown(release) + return release unless cooldown_enabled? + return release unless cooldown_options + # Commit SHA releases have no version ordering to fall back through + return release if release_type_sha? + + Dependabot.logger.info("Applying cooldown filter for #{dependency.name}") + + result = find_latest_version_outside_cooldown + return result if result + + Dependabot.logger.info("All candidate versions are in cooldown, keeping current version #{current_version}") + current_version + end + sig { returns(T.nilable(Dependabot::PreCommit::Package::PackageDetailsFetcher)) } def package_details_fetcher @package_details_fetcher ||= T.let( @@ -143,129 +150,91 @@ def cooldown_enabled? true end - sig do - params(release: T.nilable(T.any(Dependabot::Version, String))) - .returns(T.nilable(T.any(Dependabot::Version, String))) - end - def cooldown_filter(release) - return release unless cooldown_enabled? - return release unless cooldown_options + # Checks versions from latest downward (among versions > current_version) + # in a single bare clone. Returns the newest version outside cooldown, + # or nil if all candidates are within cooldown. + sig { returns(T.nilable(Dependabot::Version)) } + def find_latest_version_outside_cooldown + candidates = version_candidates_descending + return nil if candidates.empty? - Dependabot.logger.info("Initializing cooldown filter") - release_date = commit_metadata_details + url = @git_helper.git_commit_checker.dependency_source_details&.fetch(:url) + source = T.must(Source.from_url(url)) - unless release_date - Dependabot.logger.info("No release date found, skipping cooldown filtering") - return release - end + SharedHelpers.in_a_temporary_directory(File.dirname(source.repo)) do |temp_dir| + repo_contents_path = File.join(temp_dir, File.basename(source.repo)) + SharedHelpers.run_shell_command("git clone --bare --no-recurse-submodules #{url} #{repo_contents_path}") - if release_in_cooldown_period?(Time.parse(release_date)) - Dependabot.logger.info("Filtered out (cooldown) #{dependency.name}, #{release}") - return nil + Dir.chdir(repo_contents_path) do + return check_candidates_cooldown(candidates) + end end - - release + rescue StandardError => e + Dependabot.logger.error("Error checking cooldown for #{dependency.name}: #{e.message}") + nil end - sig { returns(T.nilable(Dependabot::Version)) } - def find_latest_version_not_in_cooldown - # Only applicable for version-tagged releases, not commit SHA releases - return nil if release_type_sha? - - candidate_tags = sorted_candidate_version_tags - return nil if candidate_tags.empty? - - Dependabot.logger.info("Checking #{candidate_tags.length} older versions for cooldown fallback") + # Iterates candidate tags inside a bare clone directory, returning the first + # version whose release date falls outside the cooldown window. + sig do + params(candidates: T::Array[T::Hash[Symbol, T.untyped]]) + .returns(T.nilable(Dependabot::Version)) + end + def check_candidates_cooldown(candidates) + filtered_count = 0 + + candidates.each do |tag| + commit_sha = tag[:commit_sha] + next unless commit_sha + + date_str = SharedHelpers.run_shell_command( + "git show --no-patch --format=\"%cd\" --date=iso #{commit_sha}", + fingerprint: "git show --no-patch --format=\"%cd\" --date=iso " + ) + release_date = Time.parse(date_str) + + if release_in_cooldown_period?(release_date) + filtered_count += 1 + else + log_cooldown_result(filtered_count, tag[:version], release_date) + @cooldown_selected_tag = tag + return T.cast(tag[:version], Dependabot::Version) + end + end - find_first_tag_not_in_cooldown(candidate_tags) - rescue StandardError => e - Dependabot.logger.error("Error finding fallback version: #{e.message}") + Dependabot.logger.info( + "Filtered #{filtered_count} version(s) due to cooldown for #{dependency.name}, " \ + "no eligible version found" + ) nil end + sig do + params(filtered_count: Integer, version: T.untyped, release_date: Time).void + end + def log_cooldown_result(filtered_count, version, release_date) + if filtered_count.positive? + Dependabot.logger.info( + "Filtered #{filtered_count} version(s) due to cooldown for #{dependency.name}" + ) + end + Dependabot.logger.info("Selected version #{version} (released #{release_date})") + end + + # Returns all version tags > current_version, sorted descending (latest first). + # This ensures we evaluate from the newest candidate downward. sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } - def sorted_candidate_version_tags + def version_candidates_descending all_tags = @git_helper.git_commit_checker.local_tags_for_allowed_versions_matching_existing_precision - latest_tag = latest_version_tag - latest_version_value = latest_tag&.fetch(:version, nil) cur_version = current_version all_tags .select { |tag| tag[:version].is_a?(Gem::Version) } - .reject { |tag| latest_version_value && tag[:version] == latest_version_value } .select { |tag| cur_version.nil? || tag[:version] > cur_version } .sort_by { |tag| tag[:version] } .reverse end - sig do - params(candidate_tags: T::Array[T::Hash[Symbol, T.untyped]]) - .returns(T.nilable(Dependabot::Version)) - end - def find_first_tag_not_in_cooldown(candidate_tags) - url = @git_helper.git_commit_checker.dependency_source_details&.fetch(:url) - source = T.must(Source.from_url(url)) - - SharedHelpers.in_a_temporary_directory(File.dirname(source.repo)) do |temp_dir| - repo_contents_path = File.join(temp_dir, File.basename(source.repo)) - SharedHelpers.run_shell_command("git clone --bare --no-recurse-submodules #{url} #{repo_contents_path}") - - Dir.chdir(repo_contents_path) do - candidate_tags.each do |tag| - commit_sha = tag[:commit_sha] - next unless commit_sha - - date_str = SharedHelpers.run_shell_command( - "git show --no-patch --format=\"%cd\" --date=iso #{commit_sha}", - fingerprint: "git show --no-patch --format=\"%cd\" --date=iso " - ) - release_date = Time.parse(date_str) - - unless release_in_cooldown_period?(release_date) - Dependabot.logger.info( - "Found version #{tag[:version]} not in cooldown (released #{release_date})" - ) - return T.cast(tag[:version], Dependabot::Version) - end - - Dependabot.logger.info("Version #{tag[:version]} also in cooldown, trying next") - end - end - end - - nil - end - - sig { returns(T.nilable(String)) } - def commit_metadata_details - @commit_metadata_details ||= T.let( - begin - url = @git_helper.git_commit_checker.dependency_source_details&.fetch(:url) - source = T.must(Source.from_url(url)) - - SharedHelpers.in_a_temporary_directory(File.dirname(source.repo)) do |temp_dir| - repo_contents_path = File.join(temp_dir, File.basename(source.repo)) - - SharedHelpers.run_shell_command("git clone --bare --no-recurse-submodules #{url} #{repo_contents_path}") - Dir.chdir(repo_contents_path) do - date = SharedHelpers.run_shell_command( - "git show --no-patch --format=\"%cd\" " \ - "--date=iso #{commit_ref}" - ) - Dependabot.logger.info("Found release date : #{Time.parse(date)}") - return date - end - end - rescue StandardError => e - Dependabot.logger.error("Error (pre_commit) while checking release date for #{dependency.name}") - Dependabot.logger.error(e.message) - - nil - end, - T.nilable(String) - ) - end - sig { params(release_date: Time).returns(T::Boolean) } def release_in_cooldown_period?(release_date) cooldown = @cooldown_options @@ -274,25 +243,26 @@ def release_in_cooldown_period?(release_date) days = T.must(cooldown).default_days - Dependabot.logger.info( - "Days since release : #{(Time.now.to_i - release_date.to_i) / (24 * 60 * 60)} " \ - "(cooldown days #{days})" - ) - Dependabot::UpdateCheckers::CooldownCalculation .within_cooldown_window?(release_date, days) end - sig { returns(String) } - def commit_ref - T.cast(latest_version_tag&.fetch(:commit_sha), String) - end - sig { returns(T.nilable(T.any(Dependabot::Version, String))) } def current_version return dependency.source_details(allowed_types: ["git"])&.fetch(:ref) if release_type_sha? - T.let(dependency.numeric_version, T.nilable(Dependabot::Version)) + # numeric_version handles plain versions like "4.4.0" + numeric = dependency.numeric_version + return numeric if numeric + + # Handle v-prefixed tags like "v4.4.0" common in pre-commit + version_str = dependency.version + return nil unless version_str + + stripped = version_str.sub(/\Av/i, "") + return nil unless Dependabot::PreCommit::Version.correct?(stripped) + + Dependabot::PreCommit::Version.new(stripped) end sig { returns(T::Boolean) } diff --git a/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb b/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb index a57c94a889e..fb0bc7d7925 100644 --- a/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/update_checker/latest_version_finder_spec.rb @@ -199,32 +199,35 @@ ) end - before do - # Stub commit_metadata_details to return a recent date (simulating latest in cooldown) - allow_any_instance_of(described_class) # rubocop:disable RSpec/AnyInstance - .to receive(:commit_metadata_details) - .and_return(Time.now.utc.strftime("%Y-%m-%d %H:%M:%S %z")) - end - it "falls back to a previous version not in cooldown" do + recent_date = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S %z") + old_date = (Time.now - (30 * 24 * 60 * 60)).utc.strftime("%Y-%m-%d %H:%M:%S %z") + + latest_tag = { + tag: "v6.0.0", + version: Dependabot::PreCommit::Version.new("6.0.0"), + commit_sha: "latest_sha" + } older_tag = { tag: "v5.0.0", version: Dependabot::PreCommit::Version.new("5.0.0"), - commit_sha: "abc123" + commit_sha: "older_sha" } + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance .to receive(:local_tags_for_allowed_versions_matching_existing_precision) - .and_return([older_tag]) + .and_return([latest_tag, older_tag]) allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance .to receive(:dependency_source_details) - .and_return({ url: "https://github.com/pre-commit/pre-commit-hooks" }) + .and_return({ type: "git", url: "https://github.com/pre-commit/pre-commit-hooks", + ref: "v4.4.0", branch: nil }) allow(Dependabot::SharedHelpers).to receive(:in_a_temporary_directory).and_yield("/tmp/fake") allow(Dir).to receive(:chdir).and_yield allow(Dependabot::SharedHelpers).to receive(:run_shell_command) .with(/git clone --bare/, any_args).and_return("") allow(Dependabot::SharedHelpers).to receive(:run_shell_command) - .with(/git show --no-patch/, any_args) - .and_return((Time.now - (30 * 24 * 60 * 60)).utc.strftime("%Y-%m-%d %H:%M:%S %z")) + .with(/git show --no-patch/, hash_including(fingerprint: anything)) + .and_return(recent_date, old_date) result = finder.latest_release_version expect(result.to_s).to eq("5.0.0") @@ -238,6 +241,57 @@ result = finder.latest_release_version expect(result.to_s).to eq("4.4.0") end + + context "with v-prefixed version in dependency" do + let(:dependency) do + Dependabot::Dependency.new( + name: "https://github.com/#{dependency_name}", + version: "v4.4.0", + requirements: [{ + requirement: nil, + groups: [], + file: ".pre-commit-config.yaml", + source: dependency_source + }], + package_manager: "pre_commit" + ) + end + + it "does not select versions older than the current pinned ref" do + # v3.0.0 is older than v4.4.0 — must not be selected even if not in cooldown + old_tag = { + tag: "v3.0.0", + version: Dependabot::PreCommit::Version.new("3.0.0"), + commit_sha: "old_sha" + } + latest_tag = { + tag: "v6.0.0", + version: Dependabot::PreCommit::Version.new("6.0.0"), + commit_sha: "latest_sha" + } + + recent_date = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S %z") + + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance + .to receive(:local_tags_for_allowed_versions_matching_existing_precision) + .and_return([latest_tag, old_tag]) + allow_any_instance_of(Dependabot::GitCommitChecker) # rubocop:disable RSpec/AnyInstance + .to receive(:dependency_source_details) + .and_return({ type: "git", url: "https://github.com/pre-commit/pre-commit-hooks", + ref: "v4.4.0", branch: nil }) + allow(Dependabot::SharedHelpers).to receive(:in_a_temporary_directory).and_yield("/tmp/fake") + allow(Dir).to receive(:chdir).and_yield + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/git clone --bare/, any_args).and_return("") + # All remaining candidates (v6.0.0) are in cooldown + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) + .with(/git show --no-patch/, hash_including(fingerprint: anything)) + .and_return(recent_date) + + result = finder.latest_release_version + expect(result.to_s).to eq("4.4.0") + end + end end context "with nil cooldown" do From f328361579800d1243aa1d5abf59ae4a01c9b326 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Tue, 26 May 2026 13:19:45 -0500 Subject: [PATCH 4/4] add specs for passing cooldown to all ecosystems --- .../dart_spec.rb | 52 ++++++++++++++++++ .../additional_dependency_checkers/go_spec.rb | 45 ++++++++++++++++ .../node_spec.rb | 52 ++++++++++++++++++ .../ruby_spec.rb | 54 +++++++++++++++++++ .../rust_spec.rb | 52 ++++++++++++++++++ 5 files changed, 255 insertions(+) diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/dart_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/dart_spec.rb index ae023784f96..aad3e650113 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/dart_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/dart_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" require "dependabot/pub/update_checker" require "dependabot/pre_commit/additional_dependency_checkers/dart" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Dart do let(:checker) do @@ -227,4 +228,55 @@ expect(updated.first[:source][:package_name]).to eq("intl") end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + let(:pub_checker_class) { class_double(Dependabot::Pub::UpdateChecker) } + let(:pub_checker) { instance_double(Dependabot::UpdateCheckers::Base) } + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager) + .with("pub") + .and_return(pub_checker_class) + end + + it "passes cooldown_options as update_cooldown to the pub UpdateChecker" do + allow(pub_checker_class).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(pub_checker) + allow(pub_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(pub_checker_class).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(pub_checker_class).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(pub_checker) + allow(pub_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(pub_checker_class).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/go_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/go_spec.rb index 22971ebdd36..96898e9fbdb 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/go_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/go_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" require "dependabot/pre_commit/additional_dependency_checkers/go" require "dependabot/go_modules/update_checker" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Go do let(:checker) do @@ -248,4 +249,48 @@ end end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + let(:go_checker) { instance_double(Dependabot::GoModules::UpdateChecker) } + + it "passes cooldown_options as update_cooldown to the Go UpdateChecker" do + allow(Dependabot::GoModules::UpdateChecker).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(go_checker) + allow(go_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(Dependabot::GoModules::UpdateChecker).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(Dependabot::GoModules::UpdateChecker).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(go_checker) + allow(go_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(Dependabot::GoModules::UpdateChecker).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/node_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/node_spec.rb index 97c0406b965..354b90fa308 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/node_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/node_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" require "dependabot/npm_and_yarn/update_checker" require "dependabot/pre_commit/additional_dependency_checkers/node" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Node do let(:checker) do @@ -302,4 +303,55 @@ expect(updated.first[:source][:package_name]).to eq("eslint") end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + let(:npm_checker_class) { class_double(Dependabot::NpmAndYarn::UpdateChecker) } + let(:npm_checker) { instance_double(Dependabot::UpdateCheckers::Base) } + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager) + .with("npm_and_yarn") + .and_return(npm_checker_class) + end + + it "passes cooldown_options as update_cooldown to the npm UpdateChecker" do + allow(npm_checker_class).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(npm_checker) + allow(npm_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(npm_checker_class).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(npm_checker_class).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(npm_checker) + allow(npm_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(npm_checker_class).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/ruby_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/ruby_spec.rb index a756e728ab4..8f385438c4d 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/ruby_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/ruby_spec.rb @@ -3,6 +3,7 @@ require "spec_helper" require "dependabot/pre_commit/additional_dependency_checkers/ruby" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Ruby do let(:checker) do @@ -275,4 +276,57 @@ expect(updated.first[:source][:package_name]).to eq("scss_lint") end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + # rubocop:disable RSpec/VerifiedDoubleReference + let(:bundler_checker_class) { class_double("Dependabot::Bundler::UpdateChecker") } + # rubocop:enable RSpec/VerifiedDoubleReference + let(:bundler_checker) { instance_double(Dependabot::UpdateCheckers::Base) } + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager) + .with("bundler") + .and_return(bundler_checker_class) + end + + it "passes cooldown_options as update_cooldown to the bundler UpdateChecker" do + allow(bundler_checker_class).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(bundler_checker) + allow(bundler_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(bundler_checker_class).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(bundler_checker_class).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(bundler_checker) + allow(bundler_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(bundler_checker_class).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end diff --git a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/rust_spec.rb b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/rust_spec.rb index 43e3a8d9d7f..98819ca2bb0 100644 --- a/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/rust_spec.rb +++ b/pre_commit/spec/dependabot/pre_commit/additional_dependency_checkers/rust_spec.rb @@ -4,6 +4,7 @@ require "spec_helper" require "dependabot/cargo/update_checker" require "dependabot/pre_commit/additional_dependency_checkers/rust" +require "dependabot/package/release_cooldown_options" RSpec.describe Dependabot::PreCommit::AdditionalDependencyCheckers::Rust do let(:checker) do @@ -230,4 +231,55 @@ expect(updated.first[:source][:package_name]).to eq("serde") end end + + describe "cooldown passthrough" do + let(:cooldown_options) do + Dependabot::Package::ReleaseCooldownOptions.new(default_days: 3) + end + + let(:checker_with_cooldown) do + described_class.new( + source: source, + credentials: credentials, + requirements: requirements, + current_version: current_version, + cooldown_options: cooldown_options + ) + end + + let(:cargo_checker_class) { class_double(Dependabot::Cargo::UpdateChecker) } + let(:cargo_checker) { instance_double(Dependabot::UpdateCheckers::Base) } + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager) + .with("cargo") + .and_return(cargo_checker_class) + end + + it "passes cooldown_options as update_cooldown to the cargo UpdateChecker" do + allow(cargo_checker_class).to receive(:new).with( + hash_including(update_cooldown: cooldown_options) + ).and_return(cargo_checker) + allow(cargo_checker).to receive(:latest_version).and_return(nil) + + checker_with_cooldown.latest_version + + expect(cargo_checker_class).to have_received(:new).with( + hash_including(update_cooldown: cooldown_options) + ) + end + + it "passes nil update_cooldown when no cooldown_options provided" do + allow(cargo_checker_class).to receive(:new).with( + hash_including(update_cooldown: nil) + ).and_return(cargo_checker) + allow(cargo_checker).to receive(:latest_version).and_return(nil) + + checker.latest_version + + expect(cargo_checker_class).to have_received(:new).with( + hash_including(update_cooldown: nil) + ) + end + end end