@@ -44,39 +44,170 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4444
4545 MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4646
47+ MIN_GIT_SHA_LENGTH = 7 # Git short SHA
48+ MAX_GIT_SHA_LENGTH = 40 # Git full SHA
49+ GIT_COMMIT = T . let ( /\A [0-9a-f]{#{ MIN_GIT_SHA_LENGTH } ,#{ MAX_GIT_SHA_LENGTH } }\z /i , Regexp )
50+
4751 sig { params ( comparison_version : Dependabot ::Version ) . returns ( T ::Boolean ) }
4852 def matches_dependency_version_type? ( comparison_version )
4953 return true unless dependency . version
5054
51- current_version_string = dependency . version
52- candidate_version_string = comparison_version . to_s
55+ current = dependency . version
56+ candidate = comparison_version . to_s
57+
58+ return true if pre_release_compatible? ( current , candidate )
5359
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 )
60+ return true if upgrade_to_stable? ( current , candidate )
5661
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
62+ suffix_compatible? ( current , candidate )
63+ end
6164
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
65+ private
6766
68- current_suffix = extract_version_suffix ( current_version_string )
69- candidate_suffix = extract_version_suffix ( candidate_version_string )
67+ # Determines whether two versions have compatible suffixes.
68+ #
69+ # Suffix compatibility is evaluated based on the type of suffix present:
70+ #
71+ # - Java runtime suffixes (JRE/JDK): Must have matching major versions and
72+ # compatible runtime types (JRE can upgrade to JDK, but not vice versa)
73+ #
74+ # - Git commit SHAs: Both versions must contain SHAs (the actual SHA values
75+ # don't need to match, as different commits of the same version are compatible)
76+ #
77+ # - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
78+ #
79+ # - No suffix: Both versions must have no suffix
80+ #
81+ # @example Java runtime compatibility
82+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
83+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
84+ # suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
85+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
86+ #
87+ # @example Git SHA compatibility
88+ # suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
89+ # suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => false (SHA vs. no SHA)
90+ #
91+ # @example Exact suffix matching
92+ # suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
93+ # suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
94+ # suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
95+ # suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
96+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
97+ def suffix_compatible? ( current , candidate )
98+ current_suffix = extract_version_suffix ( current )
99+ candidate_suffix = extract_version_suffix ( candidate )
70100
71101 if jre_or_jdk? ( current_suffix ) && jre_or_jdk? ( candidate_suffix )
72102 return compatible_java_runtime? ( T . must ( current_suffix ) , T . must ( candidate_suffix ) )
73103 end
74104
105+ return true if contains_git_sha? ( current_suffix ) && contains_git_sha? ( candidate_suffix )
106+
75107 # If both versions share the exact suffix or no suffix, they are compatible
76108 current_suffix == candidate_suffix
77109 end
78110
79- private
111+ # Determines whether a given string is a valid Git commit SHA.
112+ #
113+ # Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
114+ # Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
115+ #
116+ # @example Valid Git SHAs
117+ # git_sha?("a1b2c3d") # => true (7-char short SHA)
118+ # git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
119+ # git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
120+ # git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
121+ #
122+ # @example Invalid inputs
123+ # git_sha?("1.2.3") # => false (version number)
124+ # git_sha?("abc") # => false (too short, < 7 chars)
125+ # git_sha?("ghijklm") # => false (invalid hex characters)
126+ # git_sha?(nil) # => false (nil input)
127+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
128+ def git_sha? ( version )
129+ return false unless version
130+
131+ normalized = version . start_with? ( "v" ) ? version [ 1 ..-1 ] : version
132+ !!T . must ( normalized ) . match? ( GIT_COMMIT )
133+ end
134+
135+ # Determines whether a version string contains a Git commit SHA.
136+ #
137+ # This method checks if any part of a version string (when split by common
138+ # delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
139+ # cases where delimiters within the SHA itself have been replaced with
140+ # underscores or other characters.
141+
142+ # @example Standard delimiter-separated SHAs
143+ # contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
144+ # contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
145+ # contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
146+ #
147+ # @example Embedded SHAs with modified delimiters
148+ # contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
149+ # contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
150+ #
151+ # @example Non-SHA versions
152+ # contains_git_sha?("1.2.3") # => false (regular version)
153+ # contains_git_sha?("abc") # => false (too short)
154+ # contains_git_sha?(nil) # => false (nil input)
155+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
156+ def contains_git_sha? ( version )
157+ return false unless version
158+
159+ # Check if any delimiter-separated part is a SHA
160+ version . split ( /[-._]/ ) . any? { |part | git_sha? ( part ) } ||
161+ # Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
162+ git_sha? ( version . gsub ( /[-._]/ , "" ) )
163+ end
164+
165+ # Determines whether two versions are compatible based on pre-release status.
166+ #
167+ # Two versions are considered compatible if both are pre-release versions.
168+ # This allows upgrades between different pre-release qualifiers of the same
169+ # base version (e.g., RC1 → CR2, ALPHA → BETA)
170+ #
171+ # @example Compatible pre-release transitions
172+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
173+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
174+ # pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
175+ # pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
176+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
177+ def pre_release_compatible? ( current , candidate )
178+ pre_release? ( current ) && pre_release? ( candidate )
179+ end
180+
181+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
182+ def pre_release? ( version )
183+ version &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS ) || false
184+ end
185+
186+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
187+ def snapshot? ( version )
188+ version &.match? ( MAVEN_SNAPSHOT_QUALIFIER ) || false
189+ end
190+
191+ # This method allows upgrades from unstable versions (pre-releases or snapshots)
192+ # to stable releases, which is a common and expected upgrade path.
193+ # However, it prevents downgrades from stable releases back to pre-releases,
194+ # as this would violate semantic versioning expectations.
195+ #
196+ # @example Valid upgrades to stable
197+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
198+ # upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
199+ # upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
200+ # upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
201+ #
202+ # @example Invalid transitions (returns false)
203+ # upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
204+ # upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
205+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
206+ # upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
207+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
208+ def upgrade_to_stable? ( current , candidate )
209+ ( pre_release? ( current ) || snapshot? ( current ) ) && !pre_release? ( candidate )
210+ end
80211
81212 # Determines whether two Java runtime suffixes are compatible.
82213 #
@@ -182,8 +313,7 @@ def extract_version_suffix(version_string)
182313 # e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183314 return nil if suffix . match? ( /^_?\d +$/ )
184315
185- # Must contain a hyphen to be considered a valid suffix
186- return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" )
316+ return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" ) || git_sha? ( suffix )
187317 end
188318
189319 nil
0 commit comments