Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
024a8a5
Add Helm::Requirement for SemVer-range constraint parsing
casey-robertson-paypal Jun 4, 2026
d3498ca
Add Helm RequirementsUpdater for versioning-strategy
casey-robertson-paypal Jun 4, 2026
1dfc5bc
Honor versioning-strategy in helm update checker and chart updater
casey-robertson-paypal Jun 4, 2026
eb38dd7
helm: support multi-comparator range constraints + expand strategy tests
casey-robertson-paypal Jun 4, 2026
be6cf17
helm: address PR review (nil requirement, OR-range satisfaction, anch…
casey-robertson-paypal Jun 4, 2026
898ec96
helm: fix Sorbet static type errors
casey-robertson-paypal Jun 4, 2026
c8907e5
helm: harden requirement regexes against polynomial ReDoS
casey-robertson-paypal Jun 4, 2026
527a9f4
helm: correct misleading comment on default strategy behavior
casey-robertson-paypal Jun 4, 2026
6a19336
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 4, 2026
dc39b32
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 4, 2026
1f4e4b7
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 4, 2026
a32691c
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 5, 2026
2b69b14
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 5, 2026
4352acc
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 5, 2026
c8a9f06
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 5, 2026
1ac775e
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 8, 2026
36a0fa0
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 9, 2026
ad53a5d
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 9, 2026
d7f77e6
Merge upstream/main into casey/helm-versioning-strategy
casey-robertson-paypal Jun 16, 2026
0d8537e
helm: exclude requirements_updater from Sorbet/ForbidTUntyped
casey-robertson-paypal Jun 16, 2026
bb88db6
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 16, 2026
b550d2a
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 16, 2026
8157959
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 17, 2026
7f598b1
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 18, 2026
3a4f20e
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 22, 2026
b3ce984
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 22, 2026
f0ef728
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 23, 2026
d78e58b
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 23, 2026
fd49edd
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 24, 2026
5dfa210
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 24, 2026
9fc4881
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 24, 2026
9d67e91
helm: type requirements as DependencyRequirement (drop T.untyped)
casey-robertson-paypal Jun 25, 2026
642ea82
Merge remote-tracking branch 'upstream/main' into casey/helm-versioni…
casey-robertson-paypal Jun 25, 2026
0d2e0f0
helm: drop ForbidTUntyped exclude; build DependencyRequirement in specs
casey-robertson-paypal Jun 25, 2026
f216820
Merge branch 'casey/helm-versioning-strategy' of https://github.com/c…
casey-robertson-paypal Jun 25, 2026
b6ea46c
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 25, 2026
b08220d
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 25, 2026
2bf3e86
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 25, 2026
ce1c3a6
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 26, 2026
fd558b4
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jun 26, 2026
aeb50ac
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 6, 2026
be942cd
helm: re-trigger CI (GitHub API rate-limit flake on prior run)
casey-robertson-paypal Jul 6, 2026
0b01d0c
Merge branch 'casey/helm-versioning-strategy' of https://github.com/c…
casey-robertson-paypal Jul 6, 2026
836cc42
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 6, 2026
550882d
helm: annotate current_version override; document ClassLength disable
casey-robertson-paypal Jul 6, 2026
60b5b1d
Merge branch 'casey/helm-versioning-strategy' of https://github.com/c…
casey-robertson-paypal Jul 6, 2026
1a02e86
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 7, 2026
67ef423
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 7, 2026
b146023
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 7, 2026
4aea043
helm: don't report updates for in-range comparator ranges
casey-robertson-paypal Jul 8, 2026
1624dbf
helm: widen OR ranges; gate can_update? on constraint actually changing
casey-robertson-paypal Jul 8, 2026
35b065b
Merge remote-tracking branch 'upstream/main' into casey/helm-versioni…
casey-robertson-paypal Jul 8, 2026
e4becf0
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 9, 2026
2ccbd55
helm: support build metadata / digest suffixes in constraints
casey-robertson-paypal Jul 9, 2026
6c35603
Merge branch 'casey/helm-versioning-strategy' of https://github.com/c…
casey-robertson-paypal Jul 9, 2026
09fcdff
helm: add round-trip invariant spec for requirement parsing
casey-robertson-paypal Jul 9, 2026
116a77a
helm: fix upper-only range anchor, partial hyphen bound, != exclusion
casey-robertson-paypal Jul 9, 2026
59de967
helm: per-occurrence constraints, strict > exclusion, upper-only anchor
casey-robertson-paypal Jul 9, 2026
1b5fe3c
helm: commit duplicate fixture; fix uppercase X wildcard, != anchor, …
casey-robertson-paypal Jul 9, 2026
32cfbc4
helm: re-trigger CI (triage/labeler HttpError was a transient GitHub …
casey-robertson-paypal Jul 9, 2026
d894516
Merge branch 'main' into casey/helm-versioning-strategy
casey-robertson-paypal Jul 9, 2026
2bb8f66
helm: fix comparator+wildcard constraints and comma-AND upper-bound s…
casey-robertson-paypal Jul 10, 2026
6a0940a
Merge branch 'casey/helm-versioning-strategy' of https://github.com/c…
casey-robertson-paypal Jul 10, 2026
69748cd
helm: fix crashes/corruption, simplify anchor, harden multi-occurrence
casey-robertson-paypal Jul 10, 2026
4f65fa2
helm: anchor OR ranges with an unbounded branch at 0; test anchor via…
casey-robertson-paypal Jul 10, 2026
281fae4
helm: anchor range upper-bound substitution to its comparator
casey-robertson-paypal Jul 10, 2026
c03e00d
helm: anchor OR-of-exact-pins on the lowest pin, not 0
casey-robertson-paypal Jul 10, 2026
aff88dc
helm: surface unmatched chart rewrites instead of silently skipping
casey-robertson-paypal Jul 10, 2026
db0f7d7
helm: anchor multi-occurrence charts on the lowest occurrence
casey-robertson-paypal Jul 10, 2026
c9261f8
Merge branch 'main' into casey/helm-versioning-strategy
robaiken Jul 10, 2026
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
5 changes: 5 additions & 0 deletions helm/lib/dependabot/helm/file_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def updated_dependency_files

if file.name.match?(CHART_YAML_REGEXP)
updated_content = chart_updater.updated_chart_yaml_content(file)
# A dependency may span several files/entries; when a strategy leaves
# this file's constraint unchanged (e.g. an already-in-range range),
# skip it rather than emit an identical file (which would raise).
next if updated_content == file.content

updated_files << updated_file(
file: file,
content: T.must(updated_content)
Expand Down
100 changes: 80 additions & 20 deletions helm/lib/dependabot/helm/file_updater/chart_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ def initialize(dependency:)
@dependency = dependency
end

# Returns the file content with this dependency's entries rewritten. May
# return the content unchanged (e.g. a strategy that leaves an in-range
# constraint alone, or a dependency not present in this file); the file
# updater decides whether a file actually changed.
sig { params(file: Dependabot::DependencyFile).returns(T.nilable(String)) }
def updated_chart_yaml_content(file)
content = file.content
yaml_obj = YAML.safe_load(T.must(content))

content = update_chart_dependencies(T.must(content), yaml_obj, file)

raise "Expected content to change!" if content == file.content

content
update_chart_dependencies(T.must(content), yaml_obj, file)
end

private
Expand All @@ -48,26 +48,86 @@ def updated_chart_yaml_content(file)
).returns(String)
end
def update_chart_dependencies(content, yaml_obj, file)
if update_chart_dependency?(file) && yaml_obj["dependencies"]
T.cast(yaml_obj["dependencies"], T::Array[T::Hash[String, Object]]).each do |dep|
next unless dep["name"] == dependency.name

old_version = dep["version"].to_s
new_version = dependency.version

pattern = /
(\s+-\s+name:\s+#{Regexp.escape(dependency.name)}.*?\n\s+)
(version:\s+)
["']?#{Regexp.escape(old_version)}["']?
/mx
content = content.gsub(pattern) do |match|
match.gsub(/version: ["']?#{Regexp.escape(old_version)}["']?/, "version: #{new_version}")
end
return content unless update_chart_dependency?(file) && yaml_obj["dependencies"]

# Rewrite each entry once, scanning forward so repeated occurrences of
# the same chart update independently. A whole-file gsub per entry can
# alias β€” after entry A is rewritten to entry B's old version, B's pass
# would re-match the just-updated A.
cursor = 0
T.cast(yaml_obj["dependencies"], T::Array[T::Hash[String, Object]]).each do |dep|
next unless dep["name"] == dependency.name

old_version = dep["version"].to_s
new_requirement = updated_requirement_string(file, old_version) || dependency.version.to_s
# This occurrence's constraint is unchanged (the strategy left it
# alone) β€” nothing to write for it.
next if new_requirement == old_version

new_content, cursor = replace_next_entry_version(
Comment on lines +63 to +67
content,
cursor,
old_version,
yaml_safe_value(new_requirement)
)
# A changed constraint that produced no textual edit means the entry
# wasn't matched (e.g. an unusual name/version layout). Surface it
# rather than silently emitting a partial update.
if new_content == content
raise "Expected to update #{dependency.name} from #{old_version} to " \
"#{new_requirement} in #{file.name}, but no matching entry was found"
end

content = new_content
end
content
end

# Replaces this chart's next `version:` occurrence (at/after cursor) with
# new_version, returning the updated content and the position just past
# the rewrite so later entries match their own line. The name may be
# quoted; returns the content unchanged when no entry matches.
sig do
params(content: String, cursor: Integer, old_version: String, new_version: String)
.returns([String, Integer])
end
def replace_next_entry_version(content, cursor, old_version, new_version)
pattern = /
(\s+-\s+name:\s+["']?#{Regexp.escape(dependency.name)}["']?.*?\n\s+version:\s+)
["']?#{Regexp.escape(old_version)}["']?
/mx
match = pattern.match(content, cursor)
return [content, cursor] unless match
Comment thread
casey-robertson-paypal marked this conversation as resolved.

rewritten = "#{match[1]}#{new_version}"
updated = T.must(content[0...match.begin(0)]) + rewritten + T.must(content[match.end(0)..])
[updated, match.begin(0) + rewritten.length]
end

# Wrap a requirement in double quotes when it would otherwise be
# ambiguous as a YAML plain scalar: any whitespace, or a leading
# YAML-indicator character (">", "<", "~", "|", etc.). Simple values
# (exact, caret) are left bare, preserving the previous output.
sig { params(value: String).returns(String) }
def yaml_safe_value(value)
return "\"#{value}\"" if value.match?(/\s/) || value.match?(/\A[>~<|&*!%@?:#,\[\]{}]/)

value
end

# The strategy-updated requirement string for a specific chart entry,
# matched by the entry's authored version (source[:tag]) so repeated
# occurrences of the same chart name each get their own update. Falls
# back to the first requirement for the file, then to nil (exact pin).
sig { params(file: Dependabot::DependencyFile, old_version: String).returns(T.nilable(String)) }
def updated_requirement_string(file, old_version)
reqs = dependency.requirements.select do |r|
r[:file] == file.name && r.dig(:metadata, :type) == :helm_chart
end
req = reqs.find { |r| r.dig(:source, :tag) == old_version } || reqs.first
req && req[:requirement]
end

sig { params(file: Dependabot::DependencyFile).returns(T::Boolean) }
def update_chart_dependency?(file)
reqs = dependency.requirements.select { |r| r[:file] == file.name }
Expand Down
218 changes: 218 additions & 0 deletions helm/lib/dependabot/helm/requirement.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"

require "dependabot/requirement"
require "dependabot/utils"
require "dependabot/helm/version"

module Dependabot
module Helm
# Parses Helm Chart.yaml SemVer range constraints (^, ~, x-ranges, hyphen
# ranges, || OR, space-AND and comma-AND). Adapted from
# Dependabot::NpmAndYarn::Requirement: Helm uses the same SemVer family
# (Masterminds), unlike the Gem-style Docker::Requirement registered for the
# docker-image path.
#
# NOTE: this class is intentionally NOT registered as the requirement class
# for "helm" (that remains Docker::Requirement, used by the docker-image
# path and ignore conditions). It is used explicitly by the range-preserving
# requirements updater.
class Requirement < Dependabot::Requirement
extend T::Sig

AND_SEPARATOR = T.let(/(?<=[a-zA-Z0-9*])\s+(?:&+\s+)?(?!\s*[|-])/, Regexp)
OR_SEPARATOR = T.let(/(?<=[a-zA-Z0-9*])\s*\|+/, Regexp)

# Allow an optional 'v' prefix and an optional '+<build metadata/digest>'
# suffix. Helm::Version supports the latter (e.g. "1.0.119807+<digest>"
# from OCI charts), so the requirement parser must accept it too or
# constraints containing it raise BadRequirementError.
quoted = OPS.keys.map { |k| Regexp.quote(k) }.join("|")
version_pattern = "v?#{Gem::Version::VERSION_PATTERN}(?:\\+[0-9A-Za-z\\-.]+)?"

PATTERN_RAW = T.let("\\s*(#{quoted})?\\s*(#{version_pattern})\\s*".freeze, String)
PATTERN = T.let(/\A#{PATTERN_RAW}\z/, Regexp)

# Always returns a Helm::Version (never the plain Gem::Version-backed
# DefaultRequirement), so satisfaction comparisons stay Helm::Version vs
# Helm::Version β€” Helm::Version#<=> has a strict sig that rejects plain
# Gem::Version operands.
sig do
params(obj: T.any(String, Gem::Version))
.returns(T::Array[T.any(String, Dependabot::Version)])
end
def self.parse(obj)
return ["=", Helm::Version.new(obj.to_s)] if obj.is_a?(Gem::Version)

unless (matches = PATTERN.match(obj.to_s))
raise BadRequirementError, "Illformed requirement [#{obj.inspect}]"
end

[matches[1] || "=", Helm::Version.new(T.must(matches[2]))]
end

# Returns an array of requirements. At least one requirement from the
# returned array must be satisfied for a version to be valid.
sig { override.params(requirement_string: T.nilable(String)).returns(T::Array[Requirement]) }
def self.requirements_array(requirement_string)
return [new(nil)] if requirement_string.nil?

# Parentheses are extremely rare in Helm constraints; strip them.
requirement_string = requirement_string.gsub(/[()]/, "")
requirement_string.strip.split(OR_SEPARATOR).map do |req_string|
new(req_string.strip.split(AND_SEPARATOR))
end
Comment thread
casey-robertson-paypal marked this conversation as resolved.
end

sig { params(requirements: T.nilable(T.any(String, T::Array[String]))).void }
def initialize(*requirements)
requirements = requirements.flatten.compact
# `new(nil)` (from `requirements_array(nil)`) means "match anything".
# Use a literal ">= 0" so it flows through our `parse` and is backed by a
# Helm::Version β€” Gem's DefaultRequirement uses a plain Gem::Version, which
# Helm::Version#<=>'s strict sig would reject.
requirements = [">= 0"] if requirements.empty?

requirements = requirements.flat_map { |req_string| req_string.split(",").map(&:strip) }
.flat_map { |req_string| convert_helm_constraint_to_ruby_constraint(req_string) }

super
end
Comment thread
casey-robertson-paypal marked this conversation as resolved.

private

sig { params(req_string: String).returns(T.any(String, T::Array[String])) }
def convert_helm_constraint_to_ruby_constraint(req_string)
# Leave dist-tags / non-numeric leading tokens untouched.
return req_string if req_string.match?(/^([A-Za-uw-z]|v[^\d])/)

# Wildcards combined with an operator need dedicated handling before the
# generic wildcard strip below (which would corrupt them).
wildcard = wildcard_ruby_constraint(req_string)
return wildcard if wildcard

dispatch_ruby_constraint(req_string.gsub(/(?:\.|^)[xX*]/, ""))
end

# Dispatches a wildcard-stripped constraint token to its converter.
sig { params(req_string: String).returns(T.any(String, T::Array[String])) }
def dispatch_ruby_constraint(req_string)
if req_string.empty? then ">= 0"
elsif req_string.start_with?("~>") then req_string
elsif req_string.start_with?("=") then req_string.gsub(/^=*/, "")
elsif req_string.start_with?("~") then convert_tilde_req(req_string)
elsif req_string.start_with?("^") then convert_caret_req(req_string)
elsif req_string.include?(" - ") then convert_hyphen_req(req_string)
elsif req_string.match?(/[<>]/) then req_string
Comment thread
casey-robertson-paypal marked this conversation as resolved.
else ruby_range(req_string)
end
end

# Handles wildcard constraints that a leading operator would otherwise
# corrupt. Returns nil when there is no operator+wildcard to expand.
# - "<=1.x"/">1.2.x": expand from the wildcard's [floor, ceiling) span
# (stripping the wildcard first would turn "<=1.x" into "<=1", which
# wrongly rejects 1.5.0 that Masterminds accepts).
# - "*"/"^*"/"~*": a bare wildcard means "any version"; any other
# conversion yields an empty bound.
sig { params(req_string: String).returns(T.nilable(String)) }
def wildcard_ruby_constraint(req_string)
if (m = req_string.match(/\A(<=|>=|<|>)\s*(\d+(?:\.\d+)*)\.[xX*]\z/))
return convert_wildcard_comparator(T.must(m[1]), T.must(m[2]))
end

return ">= 0" if req_string.match?(/\A[\^~<>=\s]*[xX*]+\z/)
Comment thread
casey-robertson-paypal marked this conversation as resolved.

nil
end

# Expands a comparator applied to a wildcard version ("1.x" spans
# [1.0.0, 2.0.0); "1.2.x" spans [1.2.0, 1.3.0)) per Masterminds semantics:
# >= X.x -> >= floor > X.x -> >= ceiling
# < X.x -> < floor <= X.x -> < ceiling
sig { params(operator: String, prefix: String).returns(String) }
def convert_wildcard_comparator(operator, prefix)
parts = prefix.split(".").map(&:to_i)
floor = (parts + [0, 0, 0]).first(3)
ceiling_parts = parts.dup
ceiling_parts[-1] = T.must(ceiling_parts[-1]) + 1
ceiling = (ceiling_parts + [0, 0, 0]).first(3)

case operator
when ">=" then ">= #{floor.join('.')}"
when ">" then ">= #{ceiling.join('.')}"
when "<" then "< #{floor.join('.')}"
else "< #{ceiling.join('.')}" # "<="
end
end

sig { params(req_string: String).returns(String) }
def convert_tilde_req(req_string)
version = req_string.gsub(/^~\>?[\s=]*/, "")
parts = version.split(".")
parts << "0" if parts.count < 3
"~> #{parts.join('.')}"
end
Comment thread
casey-robertson-paypal marked this conversation as resolved.

sig { params(req_string: String).returns(T::Array[String]) }
def convert_hyphen_req(req_string)
parts = req_string.split(/\s+-\s+/)
lower_bound = T.must(parts[0])
upper_bound = T.must(parts[1])
lower_bound_parts = lower_bound.split(".")
lower_bound_parts.fill("0", lower_bound_parts.length...3)

upper_bound_parts = upper_bound.split(".")
upper_bound_range =
if upper_bound_parts.length < 3
# When upper bound is a partial version treat these as an X-range:
# "1.0 - 2.0" includes the whole 2.0.x series, so always increment
# the last specified component (even when it is 0) before padding β€”
# otherwise the bound becomes "< 2.0.0.a" and excludes 2.0.0.
upper_bound_parts[-1] = (T.must(upper_bound_parts[-1]).to_i + 1).to_s
upper_bound_parts.fill("0", upper_bound_parts.length...3)
"< #{upper_bound_parts.join('.')}.a"
else
"<= #{upper_bound_parts.join('.')}"
end

[">= #{lower_bound_parts.join('.')}", upper_bound_range]
end

sig { params(req_string: String).returns(String) }
def ruby_range(req_string)
parts = req_string.split(".")
# If we have three or more parts then this is an exact match
return req_string if parts.count >= 3

# If we have fewer than three parts we do a partial match
parts << "0"
"~> #{parts.join('.')}"
end

sig { params(req_string: String).returns(T::Array[String]) }
def convert_caret_req(req_string)
version = req_string.gsub(/^\^[\s=]*/, "")
parts = version.split(".")
parts.fill("x", parts.length...3)
first_non_zero = parts.find { |d| d != "0" }
first_non_zero_index =
first_non_zero ? T.must(parts.index(first_non_zero)) : parts.count - 1
# If the requirement has a blank minor or patch version increment the
# previous index value with 1
first_non_zero_index -= 1 if first_non_zero == "x"
upper_bound = parts.map.with_index do |part, i|
if i < first_non_zero_index then part
elsif i == first_non_zero_index then (part.to_i + 1).to_s
elsif i > first_non_zero_index && i == 2 then "0.a"
else "0"
end
end.join(".")

[">= #{version}", "< #{upper_bound}"]
end
end
end
end
Loading
Loading