Skip to content

Commit 2771db5

Browse files
committed
Add support for versions using git revision suffixes
1 parent 6fe6ea9 commit 2771db5

2 files changed

Lines changed: 443 additions & 47 deletions

File tree

maven/lib/dependabot/maven/shared/shared_version_finder.rb

Lines changed: 213 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,20 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
1515
# Regex to match common Maven release qualifiers that indicate stable releases.
1616
# See https://github.com/apache/maven/blob/848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java#L315-L320
1717
MAVEN_RELEASE_QUALIFIERS = /
18-
^.+[-._](
19-
RELEASE|# Official release
20-
FINAL|# Final build
21-
GA# General Availability
22-
)$
18+
^(?:.+[-._])?(
19+
RELEASE|# Official release
20+
FINAL| # Final build
21+
GA # General Availability
22+
)\d*$
2323
/ix
2424

2525
# Common Maven pre-release qualifiers.
2626
# They often indicate versions that are not yet stable but that are released to the public for testing.
2727
# Examples: 1.0.0-RC1, 2.0.0-ALPHA2, 3.1.0-BETA, 4.0.0-DEV5, etc.
2828
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html#version-identifier
2929
MAVEN_PRE_RELEASE_QUALIFIERS = /
30-
[-._]?(
30+
# Must be at start OR preceded by a delimiter
31+
(?: \A | [-._])(
3132
# --- Qualifiers that usually REQUIRE a number ---
3233
# Examples: "RC1", "BETA2", "M3", "ALPHA-1", "EAP.2"
3334
# The number differentiates multiple pre-releases; a version like "1.0.0-RC"
@@ -44,39 +45,180 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4445

4546
MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4647

48+
# Minimum and maximum lengths for Git SHAs
49+
MIN_GIT_SHA_LENGTH = 7
50+
MAX_GIT_SHA_LENGTH = 40
51+
52+
# Regex for a valid Git SHA
53+
# - Only hexadecimal characters (0-9, a-f)
54+
# - Case-insensitive
55+
# - At least one letter a-f to avoid purely numeric strings
56+
GIT_COMMIT = T.let(
57+
/\A(?=[0-9a-f]{#{MIN_GIT_SHA_LENGTH},#{MAX_GIT_SHA_LENGTH}}\z)(?=.*[a-f])/i,
58+
Regexp
59+
)
60+
4761
sig { params(comparison_version: Dependabot::Version).returns(T::Boolean) }
4862
def matches_dependency_version_type?(comparison_version)
4963
return true unless dependency.version
5064

51-
current_version_string = dependency.version
52-
candidate_version_string = comparison_version.to_s
65+
current = dependency.version
66+
candidate = comparison_version.to_s
5367

54-
current_is_pre_release = current_version_string&.match?(MAVEN_PRE_RELEASE_QUALIFIERS)
55-
candidate_is_pre_release = candidate_version_string.match?(MAVEN_PRE_RELEASE_QUALIFIERS)
68+
return true if pre_release_compatible?(current, candidate)
5669

57-
# Pre-releases are only compatible with other pre-releases
58-
# When this happens, the suffix does not need to match exactly
59-
# This allows transitions between 1.0.0-RC1 and 1.0.0-CR2, for example
60-
return true if current_is_pre_release && candidate_is_pre_release
70+
return true if upgrade_to_stable?(current, candidate)
6171

62-
current_is_snapshot = current_version_string&.match?(MAVEN_SNAPSHOT_QUALIFIER)
63-
# If the current version is a pre-release or a snapshot, allow upgrading to a stable release
64-
# This can help move from pre-release to the stable version that supersedes it,
65-
# but this should not happen vice versa as a stable release should not be downgraded to a pre-release
66-
return true if (current_is_pre_release || current_is_snapshot) && !candidate_is_pre_release
72+
suffix_compatible?(current, candidate)
73+
end
6774

68-
current_suffix = extract_version_suffix(current_version_string)
69-
candidate_suffix = extract_version_suffix(candidate_version_string)
75+
private
76+
77+
# Determines whether two versions have compatible suffixes.
78+
#
79+
# Suffix compatibility is evaluated based on the type of suffix present:
80+
#
81+
# - Java runtime suffixes (JRE/JDK): Must have matching major versions and
82+
# compatible runtime types (JRE can upgrade to JDK, but not vice versa)
83+
#
84+
# - Git commit SHAs: When any of the versions contain Git SHAs, they are considered irrelevant
85+
# for compatibility purposes,
86+
# as SHAs indicate specific build states rather than compatibility constraints.
87+
#
88+
# - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
89+
#
90+
# - No suffix: Both versions must have no suffix
91+
#
92+
# @example Java runtime compatibility
93+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
94+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
95+
# suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
96+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
97+
#
98+
# @example Git SHA compatibility
99+
# suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
100+
# suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => true ( considered irrelevant for compatibility)
101+
#
102+
# @example Exact suffix matching
103+
# suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
104+
# suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
105+
# suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
106+
# suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
107+
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
108+
def suffix_compatible?(current, candidate)
109+
current_suffix = extract_version_suffix(current)
110+
candidate_suffix = extract_version_suffix(candidate)
70111

71112
if jre_or_jdk?(current_suffix) && jre_or_jdk?(candidate_suffix)
72113
return compatible_java_runtime?(T.must(current_suffix), T.must(candidate_suffix))
73114
end
74115

116+
return true if contains_git_sha?(current_suffix) || contains_git_sha?(candidate_suffix)
117+
75118
# If both versions share the exact suffix or no suffix, they are compatible
76119
current_suffix == candidate_suffix
77120
end
78121

79-
private
122+
# Determines whether a given string is a valid Git commit SHA.
123+
#
124+
# Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
125+
# Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
126+
#
127+
# @example Valid Git SHAs
128+
# git_sha?("a1b2c3d") # => true (7-char short SHA)
129+
# git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
130+
# git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
131+
# git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
132+
#
133+
# @example Invalid inputs
134+
# git_sha?("1.2.3") # => false (version number)
135+
# git_sha?("abc") # => false (too short, < 7 chars)
136+
# git_sha?("ghijklm") # => false (invalid hex characters)
137+
# git_sha?(nil) # => false (nil input)
138+
sig { params(version: T.nilable(String)).returns(T::Boolean) }
139+
def git_sha?(version)
140+
return false unless version
141+
142+
normalized = version.start_with?("v") ? version[1..-1] : version
143+
!!T.must(normalized).match?(GIT_COMMIT)
144+
end
145+
146+
# Determines whether a version string contains a Git commit SHA.
147+
#
148+
# This method checks if any part of a version string (when split by common
149+
# delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
150+
# cases where delimiters within the SHA itself have been replaced with
151+
# underscores or other characters.
152+
153+
# @example Standard delimiter-separated SHAs
154+
# contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
155+
# contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
156+
# contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
157+
#
158+
# @example Embedded SHAs with modified delimiters
159+
# contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
160+
# contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
161+
#
162+
# @example Non-SHA versions
163+
# contains_git_sha?("1.2.3") # => false (regular version)
164+
# contains_git_sha?("abc") # => false (too short)
165+
# contains_git_sha?(nil) # => false (nil input)
166+
sig { params(version: T.nilable(String)).returns(T::Boolean) }
167+
def contains_git_sha?(version)
168+
return false unless version
169+
170+
# Check if any delimiter-separated part is a SHA
171+
version.split(/[-._]/).any? { |part| git_sha?(part) } ||
172+
# Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
173+
git_sha?(version.gsub(/[-._]/, ""))
174+
end
175+
176+
# Determines whether two versions are compatible based on pre-release status.
177+
#
178+
# Two versions are considered compatible if both are pre-release versions.
179+
# This allows upgrades between different pre-release qualifiers of the same
180+
# base version (e.g., RC1 → CR2, ALPHA → BETA)
181+
#
182+
# @example Compatible pre-release transitions
183+
# pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
184+
# pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
185+
# pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
186+
# pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
187+
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
188+
def pre_release_compatible?(current, candidate)
189+
pre_release?(current) && pre_release?(candidate)
190+
end
191+
192+
sig { params(version: T.nilable(String)).returns(T::Boolean) }
193+
def pre_release?(version)
194+
version&.match?(MAVEN_PRE_RELEASE_QUALIFIERS) || false
195+
end
196+
197+
sig { params(version: T.nilable(String)).returns(T::Boolean) }
198+
def snapshot?(version)
199+
version&.match?(MAVEN_SNAPSHOT_QUALIFIER) || false
200+
end
201+
202+
# This method allows upgrades from unstable versions (pre-releases or snapshots)
203+
# to stable releases, which is a common and expected upgrade path.
204+
# However, it prevents downgrades from stable releases back to pre-releases,
205+
# as this would violate semantic versioning expectations.
206+
#
207+
# @example Valid upgrades to stable
208+
# upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
209+
# upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
210+
# upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
211+
# upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
212+
#
213+
# @example Invalid transitions (returns false)
214+
# upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
215+
# upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
216+
# upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
217+
# upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
218+
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
219+
def upgrade_to_stable?(current, candidate)
220+
(pre_release?(current) || snapshot?(current)) && !pre_release?(candidate)
221+
end
80222

81223
# Determines whether two Java runtime suffixes are compatible.
82224
#
@@ -151,44 +293,68 @@ def jdk?(version)
151293
# Extracts the qualifier/suffix from a Maven version string.
152294
#
153295
# Maven versions consist of numeric parts and optional string qualifiers.
154-
# This method identifies the suffix by finding the first segment (separated by '.')
155-
# that contains a non-digit character.
296+
# This method identifies the suffix by splitting on '.' and delegating
297+
# each non-numeric segment to extract_suffix_from_part.
298+
#
299+
# @example
300+
# extract_version_suffix("1.0.0.jre8") # => "jre8"
301+
# extract_version_suffix("1.0.0-linux") # => "_linux"
302+
# extract_version_suffix("1.0.0-RELEASE") # => nil (stable release qualifier)
303+
# extract_version_suffix("1.0.0") # => nil (no suffix)
156304
sig { params(version_string: T.nilable(String)).returns(T.nilable(String)) }
157305
def extract_version_suffix(version_string)
158306
return nil unless version_string
159-
160-
# Exclude common Maven release qualifiers that indicate stable releases
161307
return nil if version_string.match?(MAVEN_RELEASE_QUALIFIERS)
162308

163309
version_string.split(".").each do |part|
164-
# Skip fully numeric segments
165310
next if part.match?(/\A\d+\z/)
166311

167-
# strip leading digits and capture the suffix
168-
suffix = part.sub(/\A\d+/, "")
169-
# Normalize delimiters to ensure consistent comparison
170-
# e.g., "beta-1" and "beta_1" are treated the same
171-
suffix = suffix.tr("-", "_")
172-
173-
# Special case for JDK/JRE suffixes
174-
# e.g., "13.2.1.jre8" or "13.2.1-jdk8"
175-
# In Java, these suffixes often indicate compatibility with specific Java runtimes
176-
# and are meaningful in version comparisons as we should not mix versions built for different runtimes.
177-
# For example, "1.0.0.jdk8" should not be considered the same as "1.0.0.jdk11"
178-
# because they target different Java versions.
179-
return suffix if jre_or_jdk?(suffix)
180-
181-
# Ignore purely numeric suffixes (e.g., "-1", "_2")
182-
# e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183-
return nil if suffix.match?(/^_?\d+$/)
184-
185-
# Must contain a hyphen to be considered a valid suffix
186-
return suffix if suffix.include?("-") || suffix.include?("_")
312+
suffix = extract_suffix_from_part(part)
313+
return suffix unless suffix.nil?
187314
end
188315

189316
nil
190317
end
191318

319+
# Extracts a meaningful suffix from a single dot-separated version segment.
320+
#
321+
# Strips any leading digits, normalizes '-' to '_', then classifies the
322+
# remainder according to the following rules:
323+
#
324+
# - JRE/JDK suffixes are returned as-is for runtime compatibility checks.
325+
# - Purely numeric suffixes (e.g., "-1", "_2") are ignored and return nil.
326+
# - Suffixes containing delimiters or matching a Git SHA are returned as-is.
327+
# - Any other non-empty string is returned as a catch-all to prevent two
328+
# distinct suffixes from both collapsing to nil and appearing compatible.
329+
# - Empty strings return nil (no meaningful suffix present).
330+
#
331+
# @example
332+
# extract_suffix_from_part("13jre8") # => "jre8"
333+
# extract_suffix_from_part("0_linux") # => "_linux"
334+
# extract_suffix_from_part("0_1") # => nil (purely numeric)
335+
# extract_suffix_from_part("0abc123") # => "abc123"
336+
# extract_suffix_from_part("123") # => nil (skipped by caller)
337+
sig { params(part: String).returns(T.nilable(String)) }
338+
def extract_suffix_from_part(part)
339+
suffix = part.sub(/\A\d+/, "").tr("-", "_")
340+
341+
# Special case for JDK/JRE suffixes
342+
# e.g., "13.2.1.jre8" or "13.2.1-jdk8"
343+
# In Java, these suffixes often indicate compatibility with specific Java runtimes
344+
# and are meaningful in version comparisons as we should not mix versions built for different runtimes.
345+
# For example, "1.0.0.jdk8" should not be considered the same as "1.0.0.jdk11"
346+
# because they target different Java versions.
347+
return suffix if jre_or_jdk?(suffix)
348+
349+
# Ignore purely numeric suffixes (e.g., "-1", "_2")
350+
# e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
351+
return nil if suffix.match?(/^_?\d+$/)
352+
353+
return suffix if suffix.include?("-") || suffix.include?("_") || git_sha?(suffix)
354+
355+
suffix.empty? ? nil : suffix
356+
end
357+
192358
sig { override.returns(T.nilable(Dependabot::Package::PackageDetails)) }
193359
def package_details
194360
raise NotImplementedError, "Subclasses must implement `package_details`"

0 commit comments

Comments
 (0)