Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 106 additions & 13 deletions updater/lib/dependabot/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
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(
Expand Down Expand Up @@ -301,7 +300,10 @@ 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
# NOTE: update-types (semver-based filtering) is handled in ignore_conditions_for,
# not here — allowed_update? runs pre-resolution when only the current version is
# known, and semver-level filtering needs version ranges computed from that version.
#
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/CyclomaticComplexity
sig { params(dependency: Dependency, check_previous_version: T::Boolean).returns(T::Boolean) }
Expand All @@ -324,15 +326,6 @@ 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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Tip: For allow update types we need previous and current versions to ignore updates that are not allowed for the dependency name or dependency-type. We can only apply this in post resolution to see if update is necessary or not. It is different then ignore conditions where we are adding range versions conditions and using this to filter out the other versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


# Check the dependency-type (defaulting to all)
dep_type = update.fetch("dependency-type", "all")
next false if dep_type == "indirect" &&
Expand All @@ -348,7 +341,6 @@ def allowed_update?(dependency, check_previous_version: false)
true
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/CyclomaticComplexity

Expand Down Expand Up @@ -443,10 +435,16 @@ def security_advisories_for(dependency)

sig { params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
def ignore_conditions_for(dependency)
update_config.ignored_versions_for(
conditions = update_config.ignored_versions_for(
dependency,
security_updates_only: security_updates_only?
)

# Supplement with implicit ignore ranges derived from allow update-types.
# allow update-types cannot be checked in allowed_update? because it runs
# pre-resolution when only the current version is known. Version ranges
# only need the current version — the same mechanism as ignore update-types.
conditions + ignored_versions_from_allowed_update_types(dependency)
end

# TODO: Present Dependabot::Config::IgnoreCondition in calling code
Expand Down Expand Up @@ -485,6 +483,101 @@ def completely_ignored?(dependency)
ignore_conditions_for(dependency).any?(Dependabot::Config::IgnoreCondition::ALL_VERSIONS)
end

# Derives implicit ignore version ranges from allow rules that specify update-types.
# For example, if allow says only "semver-patch", this computes ignore ranges for
# major and minor — using the same mechanism as IgnoreCondition#versions_by_type.
sig { params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
def ignored_versions_from_allowed_update_types(dependency)
return [] if security_updates_only?

permitted_types = collect_permitted_update_types(dependency)
return [] if permitted_types.empty?

disallowed_types = Dependabot::Updater::UpdateTypeHelper::ALL_SEMVER_UPDATE_TYPES - permitted_types

version = version_for_dependency(dependency)
return [] unless version

disallowed_types.flat_map do |t|
case t
when Dependabot::Config::IgnoreCondition::PATCH_VERSION_TYPE
version.ignored_patch_versions
when Dependabot::Config::IgnoreCondition::MINOR_VERSION_TYPE
version.ignored_minor_versions
when Dependabot::Config::IgnoreCondition::MAJOR_VERSION_TYPE
version.ignored_major_versions
else
[]
end
end.compact
end

# Collects the union of update-types from all matching allow rules for a dependency.
# Returns empty if no matching rules specify update-types (meaning no filtering needed).
# If any matching rule lacks update-types, it permits all types — returns empty.
sig { params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
def collect_permitted_update_types(dependency)
matching_rules = matching_allow_rules(dependency)
return [] if matching_rules.empty?

# If any matching rule lacks update-types, it permits all types
return [] if matching_rules.any? { |r| allow_rule_permits_all_types?(r) }

matching_rules
.flat_map { |r| r.fetch("update-types", []) }
.filter_map { |t| t.is_a?(String) ? t.downcase.strip : nil }
.select { |t| Dependabot::Updater::UpdateTypeHelper::ALL_SEMVER_UPDATE_TYPES.include?(t) }
.uniq
end

sig { params(dependency: Dependabot::Dependency).returns(T::Array[T::Hash[String, T.untyped]]) }
def matching_allow_rules(dependency)
allowed_updates.select do |update|
allow_rule_matches_dependency?(update, dependency)
end
end

sig { params(update: T::Hash[String, T.untyped], dependency: Dependabot::Dependency).returns(T::Boolean) }
def allow_rule_matches_dependency?(update, dependency)
condition_name = update.fetch("dependency-name", nil)
return false if condition_name && !name_match?(condition_name, dependency.name)

dep_type = update.fetch("dependency-type", nil)
return true if dep_type.nil? || dep_type == "all"

# Indirect deps don't match top-level type rules (matching allowed_update? behavior)
return false if dependency.requirements.none? && TOP_LEVEL_DEPENDENCY_TYPES.include?(dep_type)

case dep_type
when "production" then dependency.production?
when "development" then !dependency.production?
when "direct" then dependency.requirements.any?
when "indirect" then dependency.requirements.none?
else true
end
end

sig { params(rule: T::Hash[String, T.untyped]).returns(T::Boolean) }
def allow_rule_permits_all_types?(rule)
!rule.key?("update-types") || !rule["update-types"].is_a?(Array) || rule["update-types"].empty?
end

sig { params(dependency: Dependabot::Dependency).returns(T.nilable(Dependabot::Version)) }
def version_for_dependency(dependency)
version_str = dependency.version
return nil if version_str.nil? || version_str.empty?

version_class = begin
Dependabot::Utils.version_class_for_package_manager(dependency.package_manager)
rescue StandardError
Dependabot::Version
end

return nil unless version_class.correct?(version_str)

version_class.new(version_str)
end

sig { void }
def register_experiments
experiments.entries.each do |name, value|
Expand Down
35 changes: 30 additions & 5 deletions updater/lib/dependabot/updater/update_type_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# frozen_string_literal: true

require "sorbet-runtime"
require "dependabot/config/ignore_condition"
require "dependabot/utils"

module Dependabot
Expand All @@ -12,6 +13,24 @@ class Updater
module UpdateTypeHelper
extend T::Sig

SEMVER_MAJOR = "major"
SEMVER_MINOR = "minor"
SEMVER_PATCH = "patch"

SEMVER_LABEL_TO_UPDATE_TYPE = T.let(
{
SEMVER_MAJOR => Dependabot::Config::IgnoreCondition::MAJOR_VERSION_TYPE,
SEMVER_MINOR => Dependabot::Config::IgnoreCondition::MINOR_VERSION_TYPE,
SEMVER_PATCH => Dependabot::Config::IgnoreCondition::PATCH_VERSION_TYPE
}.freeze,
T::Hash[String, String]
)

ALL_SEMVER_UPDATE_TYPES = T.let(
SEMVER_LABEL_TO_UPDATE_TYPE.values.freeze,
T::Array[String]
)

# Represents semantic version components (major, minor, patch)
class SemverParts < T::Struct
const :major, Integer
Expand Down Expand Up @@ -58,11 +77,11 @@ def classify_semver_update(prev, curr)
curr_parts = semver_parts(curr)
return nil if prev_parts.nil? || curr_parts.nil?

return "major" if curr_parts.major > prev_parts.major
return "minor" if curr_parts.major == prev_parts.major && curr_parts.minor > prev_parts.minor
return "patch" if curr_parts.major == prev_parts.major &&
curr_parts.minor == prev_parts.minor &&
curr_parts.patch > prev_parts.patch
return SEMVER_MAJOR if curr_parts.major > prev_parts.major
return SEMVER_MINOR if curr_parts.major == prev_parts.major && curr_parts.minor > prev_parts.minor
return SEMVER_PATCH if curr_parts.major == prev_parts.major &&
curr_parts.minor == prev_parts.minor &&
curr_parts.patch > prev_parts.patch

Dependabot.logger.info(
"Could not classify semver update: #{prev_parts.major}.#{prev_parts.minor}.#{prev_parts.patch} -> " \
Expand Down Expand Up @@ -119,6 +138,12 @@ def update_type_for_dependency(dep)
prev_ver, curr_ver = versions
classify_semver_update(prev_ver, curr_ver)
end

# Maps a semver label ("major", "minor", "patch") to the full config update-type string
sig { params(label: String).returns(T.nilable(String)) }
def semver_label_to_update_type(label)
SEMVER_LABEL_TO_UPDATE_TYPE[label]
end
end
end
end
Loading
Loading