diff --git a/updater/lib/dependabot/job.rb b/updater/lib/dependabot/job.rb index 9ee1cc25abc..4dee011b16a 100644 --- a/updater/lib/dependabot/job.rb +++ b/updater/lib/dependabot/job.rb @@ -13,6 +13,7 @@ require "dependabot/source" require "dependabot/pull_request" require "dependabot/package/release_cooldown_options" +require "dependabot/updater/update_type_helper" # Describes a single Dependabot workload within the GitHub-integrated Service # @@ -27,6 +28,7 @@ module Dependabot class Job # rubocop:disable Metrics/ClassLength extend T::Sig + include Dependabot::Updater::UpdateTypeHelper TOP_LEVEL_DEPENDENCY_TYPES = T.let(%w(direct production development).freeze, T::Array[String]) PERMITTED_KEYS = T.let( @@ -299,6 +301,7 @@ def reject_external_code? # was vulnerable instead of the current version. This prevents security updates # from being filtered out after the dependency has already been updated in group scenarios. # + # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/PerceivedComplexity # rubocop:disable Metrics/CyclomaticComplexity sig { params(dependency: Dependency, check_previous_version: T::Boolean).returns(T::Boolean) } @@ -321,6 +324,15 @@ def allowed_update?(dependency, check_previous_version: false) condition_name = update.fetch("dependency-name", dependency.name) next false unless name_match?(condition_name, dependency.name) + # Check update-types (semver-based filtering) - security updates bypass this + allowed_update_types = update.fetch("update-types", nil) + if allowed_update_types.is_a?(Array) && !allowed_update_types.empty? && !security_update + dep_update_type = update_type_for_dependency(dependency) + config_type = "version-update:semver-#{dep_update_type}" if dep_update_type + normalized_types = allowed_update_types.filter_map { |t| t.is_a?(String) ? t.downcase.strip : nil } + next false if config_type && !normalized_types.include?(config_type) + end + # Check the dependency-type (defaulting to all) dep_type = update.fetch("dependency-type", "all") next false if dep_type == "indirect" && @@ -336,6 +348,7 @@ def allowed_update?(dependency, check_previous_version: false) true end end + # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Metrics/CyclomaticComplexity diff --git a/updater/lib/dependabot/updater/group_dependency_selector.rb b/updater/lib/dependabot/updater/group_dependency_selector.rb index 6191c583236..515dbfe2e80 100644 --- a/updater/lib/dependabot/updater/group_dependency_selector.rb +++ b/updater/lib/dependabot/updater/group_dependency_selector.rb @@ -281,25 +281,6 @@ def group_applies_to T.unsafe(@group).applies_to end - sig { params(dep: Dependabot::Dependency).returns(T.nilable(String)) } - def update_type_for_dependency(dep) - prev_str = dep.respond_to?(:previous_version) ? dep.previous_version&.to_s : nil - curr_str = dep.respond_to?(:version) ? dep.version&.to_s : nil - return nil unless prev_str && curr_str - - version_class = version_class_for(dep) - return nil unless version_class - - update_type = update_type_from_class(version_class, prev_str, curr_str) - return update_type if update_type - - versions = build_versions(version_class, prev_str, curr_str) - return nil unless versions - - prev_ver, curr_ver = versions - classify_semver_update(prev_ver, curr_ver) - end - sig { params(dep: Dependabot::Dependency, job: Dependabot::Job).returns(T::Boolean) } def allowed_by_config?(dep, job) ignore_conditions = job.ignore_conditions_for(dep) diff --git a/updater/lib/dependabot/updater/update_type_helper.rb b/updater/lib/dependabot/updater/update_type_helper.rb index af3483a29ec..ad3e4ec2c06 100644 --- a/updater/lib/dependabot/updater/update_type_helper.rb +++ b/updater/lib/dependabot/updater/update_type_helper.rb @@ -98,6 +98,27 @@ def semver_parts(version) SemverParts.new(major: major, minor: minor, patch: patch) end + + # Determines the semver update type ("major", "minor", "patch") for a dependency + # by comparing its previous and current versions. Returns nil if it cannot be determined. + sig { params(dep: Dependabot::Dependency).returns(T.nilable(String)) } + def update_type_for_dependency(dep) + prev_str = dep.respond_to?(:previous_version) ? dep.previous_version&.to_s : nil + curr_str = dep.respond_to?(:version) ? dep.version&.to_s : nil + return nil unless prev_str && curr_str + + version_class = version_class_for(dep) + return nil unless version_class + + update_type = update_type_from_class(version_class, prev_str, curr_str) + return update_type if update_type + + versions = build_versions(version_class, prev_str, curr_str) + return nil unless versions + + prev_ver, curr_ver = versions + classify_semver_update(prev_ver, curr_ver) + end end end end diff --git a/updater/spec/dependabot/job_spec.rb b/updater/spec/dependabot/job_spec.rb index bd4c8d592ac..9df296dab69 100644 --- a/updater/spec/dependabot/job_spec.rb +++ b/updater/spec/dependabot/job_spec.rb @@ -454,6 +454,239 @@ it { is_expected.to be(false) } end + + context "with update-types in allow block" do + let(:dependency) do + Dependabot::Dependency.new( + name: "business", + package_manager: "bundler", + version: "1.9.0", + previous_version: "1.8.0", + requirements: requirements + ) + end + + context "when allowing minor updates and dependency has minor update" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-minor"] + } + ] + end + + it { is_expected.to be(true) } + end + + context "when allowing only patch updates but dependency has minor update" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-patch"] + } + ] + end + + it { is_expected.to be(false) } + end + + context "when allowing multiple update types including minor" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-patch", "version-update:semver-minor"] + } + ] + end + + it { is_expected.to be(true) } + end + + context "with a major version update" do + let(:dependency) do + Dependabot::Dependency.new( + name: "business", + package_manager: "bundler", + version: "2.0.0", + previous_version: "1.8.0", + requirements: requirements + ) + end + + context "when allowing major updates" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-major"] + } + ] + end + + it { is_expected.to be(true) } + end + + context "when only allowing minor updates" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-minor"] + } + ] + end + + it { is_expected.to be(false) } + end + end + + context "with a patch version update" do + let(:dependency) do + Dependabot::Dependency.new( + name: "business", + package_manager: "bundler", + version: "1.8.1", + previous_version: "1.8.0", + requirements: requirements + ) + end + + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-patch"] + } + ] + end + + it { is_expected.to be(true) } + end + + context "when combining update-types with dependency-type" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "dependency-type" => "direct", + "update-types" => ["version-update:semver-minor", "version-update:semver-patch"] + } + ] + end + + it { is_expected.to be(true) } + end + + context "when dependency has no previous version" do + let(:dependency) do + Dependabot::Dependency.new( + name: "business", + package_manager: "bundler", + version: "1.9.0", + previous_version: nil, + requirements: requirements + ) + end + + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-minor"] + } + ] + end + + it "allows update when semver type cannot be determined" do + expect(allowed_update).to be(true) + end + end + + context "when dependency name does not match" do + let(:allowed_updates) do + [ + { + "dependency-name" => "other-dep", + "update-types" => ["version-update:semver-minor"] + } + ] + end + + it { is_expected.to be(false) } + end + + context "when update-types is empty array" do + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => [] + } + ] + end + + it "treats empty update-types as no filtering" do + expect(allowed_update).to be(true) + end + end + + context "when one allow rule has update-types and another does not" do + let(:dependency) do + Dependabot::Dependency.new( + name: "business", + package_manager: "bundler", + version: "2.0.0", + previous_version: "1.0.0", + requirements: requirements + ) + end + + let(:allowed_updates) do + [ + { + "dependency-name" => "business", + "update-types" => ["version-update:semver-patch"] + }, + { + "dependency-name" => "business" + } + ] + end + + it "allows if any rule matches (second rule has no update-types filter)" do + expect(allowed_update).to be(true) + end + end + + context "with security updates" do + let(:security_advisories) do + [ + { + "dependency-name" => "business", + "affected-versions" => [], + "patched-versions" => ["~> 1.11.0"], + "unaffected-versions" => [] + } + ] + end + + let(:allowed_updates) do + [ + { + "update-type" => "security", + "update-types" => ["version-update:semver-patch"] + } + ] + end + + it "bypasses update-types filtering for security updates" do + expect(allowed_update).to be(true) + end + end + end end describe "#security_updates_only?" do diff --git a/updater/spec/dependabot/updater/update_type_helper_spec.rb b/updater/spec/dependabot/updater/update_type_helper_spec.rb index 1b085a82626..d6e573eeecf 100644 --- a/updater/spec/dependabot/updater/update_type_helper_spec.rb +++ b/updater/spec/dependabot/updater/update_type_helper_spec.rb @@ -170,4 +170,75 @@ expect(helper.classify_semver_update(prev_version, curr_version)).to eq("major") end end + + describe "#update_type_for_dependency" do + before do + allow(Dependabot).to receive(:logger).and_return(instance_double(Logger, info: nil)) + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "my-gem", + version: current_version, + previous_version: previous_version, + requirements: [], + previous_requirements: [], + package_manager: "dummy" + ) + end + + context "when it is a major update" do + let(:previous_version) { "1.0.0" } + let(:current_version) { "2.0.0" } + + it "returns 'major'" do + expect(helper.update_type_for_dependency(dependency)).to eq("major") + end + end + + context "when it is a minor update" do + let(:previous_version) { "1.0.0" } + let(:current_version) { "1.1.0" } + + it "returns 'minor'" do + expect(helper.update_type_for_dependency(dependency)).to eq("minor") + end + end + + context "when it is a patch update" do + let(:previous_version) { "1.0.0" } + let(:current_version) { "1.0.1" } + + it "returns 'patch'" do + expect(helper.update_type_for_dependency(dependency)).to eq("patch") + end + end + + context "when previous_version is nil" do + let(:previous_version) { nil } + let(:current_version) { "1.0.0" } + + it "returns nil" do + expect(helper.update_type_for_dependency(dependency)).to be_nil + end + end + + context "when version is nil" do + let(:previous_version) { "1.0.0" } + let(:current_version) { nil } + + it "returns nil" do + expect(helper.update_type_for_dependency(dependency)).to be_nil + end + end + + context "when versions are the same" do + let(:previous_version) { "1.0.0" } + let(:current_version) { "1.0.0" } + + it "returns nil" do + expect(helper.update_type_for_dependency(dependency)).to be_nil + end + end + end end