Skip to content

Commit 4851f69

Browse files
committed
Add support for versions using git revision suffixes
1 parent c18cade commit 4851f69

File tree

2 files changed

+328
-18
lines changed

2 files changed

+328
-18
lines changed

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

Lines changed: 157 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,39 +44,179 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4444

4545
MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4646

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

51-
current_version_string = dependency.version
52-
candidate_version_string = comparison_version.to_s
64+
current = dependency.version
65+
candidate = comparison_version.to_s
66+
67+
return true if pre_release_compatible?(current, candidate)
5368

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)
69+
return true if upgrade_to_stable?(current, candidate)
5670

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
71+
suffix_compatible?(current, candidate)
72+
end
6173

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
74+
private
6775

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

71110
if jre_or_jdk?(current_suffix) && jre_or_jdk?(candidate_suffix)
72111
return compatible_java_runtime?(T.must(current_suffix), T.must(candidate_suffix))
73112
end
74113

114+
return true if contains_git_sha?(current_suffix) || contains_git_sha?(candidate_suffix)
115+
75116
# If both versions share the exact suffix or no suffix, they are compatible
76117
current_suffix == candidate_suffix
77118
end
78119

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

81221
# Determines whether two Java runtime suffixes are compatible.
82222
#
@@ -182,8 +322,7 @@ def extract_version_suffix(version_string)
182322
# e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183323
return nil if suffix.match?(/^_?\d+$/)
184324

185-
# Must contain a hyphen to be considered a valid suffix
186-
return suffix if suffix.include?("-") || suffix.include?("_")
325+
return suffix if suffix.include?("-") || suffix.include?("_") || git_sha?(suffix)
187326
end
188327

189328
nil

maven/spec/dependabot/maven/shared/shared_version_finder_spec.rb

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,177 @@
467467
end
468468
end
469469
end
470+
471+
context "when the dependency version uses git commit for the delimiter" do
472+
# Some tests are based on real-world examples from Jenkin's plugin release conventions
473+
# See
474+
# https://www.jenkins.io/doc/developer/publishing/releasing-cd/
475+
# https://github.com/jenkinsci/jep/blob/master/jep/229/README.adoc
476+
477+
context "when the version contains embedded git commits" do
478+
let(:dependency_version) { "6.2108.v08c2b_01b_cf4d" }
479+
let(:comparison_version) { "6.2122.v70b_7b_f659d72" }
480+
481+
it { is_expected.to be true }
482+
end
483+
484+
context "when the version has a single version with embedded git commit" do
485+
let(:dependency_version) { "5622.c9c3051619f5" }
486+
let(:comparison_version) { "5681.79d2ddf61465" }
487+
488+
it { is_expected.to be true }
489+
end
490+
491+
context "when version has semantic version with git SHA and build number" do
492+
# Format: {semver}-{build}.v{gitsha}
493+
# Example from https://plugins.jenkins.io/caffeine-api/
494+
let(:dependency_version) { "2.9.2-29.v717aac953ff3" }
495+
let(:comparison_version) { "2.9.3-30.va1b2c3d4e5f6" }
496+
497+
it { is_expected.to be true }
498+
end
499+
500+
context "when version has four-digit revision with git SHA" do
501+
# Format: {revision}.v{gitsha}
502+
# Example from credentials plugin
503+
let(:dependency_version) { "1074.v60e6c29b_b_44b_" }
504+
let(:comparison_version) { "1087.1089.v2f1b_9a_b_040e4" }
505+
506+
it { is_expected.to be true }
507+
end
508+
509+
context "when version has multi-part revision with git SHA" do
510+
# Format: {major}.{revision}.v{gitsha}
511+
# Example from credentials plugin
512+
let(:dependency_version) { "1087.1089.v2f1b_9a_b_040e4" }
513+
let(:comparison_version) { "1087.v16065d268466" }
514+
515+
it { is_expected.to be true }
516+
end
517+
518+
context "when version has three-digit build with git SHA" do
519+
# Format: {build}.v{gitsha}
520+
# Example from jackson2-api plugin
521+
let(:dependency_version) { "230.v59243c64b0a5" }
522+
let(:comparison_version) { "246.va8a9f3eaf46a" }
523+
524+
it { is_expected.to be true }
525+
end
526+
527+
context "when version has longer multi-part format" do
528+
# Format: {major}.{minor}.{patch}.{build}.v{gitsha}
529+
# Example from https://plugins.jenkins.io/aws-java-sdk/
530+
let(:dependency_version) { "1.12.163-315.v2b_716ec8e4df" }
531+
let(:comparison_version) { "1.12.170-320.v3c4d5e6f7g8h" }
532+
533+
it { is_expected.to be true }
534+
end
535+
536+
context "when both versions have different delimiter styles in git SHA" do
537+
# Both have git SHAs but different underscore patterns
538+
let(:dependency_version) { "100.v60e6c29b_b_44b_" }
539+
let(:comparison_version) { "105.va_b_018a_a_6b_0d3" }
540+
541+
it { is_expected.to be true }
542+
end
543+
544+
context "when the version has a short git commit" do
545+
let(:dependency_version) { "5622.c9c3051" }
546+
let(:comparison_version) { "5681.c9c3051" }
547+
548+
it { is_expected.to be true }
549+
end
550+
551+
context "when the version has a mix of short and long git commits" do
552+
let(:dependency_version) { "5622.c9c3051" }
553+
let(:comparison_version) { "5681.c9c3051619f5" }
554+
555+
it { is_expected.to be true }
556+
end
557+
558+
context "when the version has a single embedded git commit using different delimiters" do
559+
let(:dependency_version) { "5622-c9c3051619f5" }
560+
let(:comparison_version) { "5681.79d2ddf61465" }
561+
562+
it { is_expected.to be true }
563+
end
564+
565+
context "when the version has a single embedded git commit with the v suffix" do
566+
# Example: https://github.com/jenkinsci/bom/releases/tag/5622.vc9c3051619f5
567+
let(:dependency_version) { "5622.vc9c3051619f5" }
568+
let(:comparison_version) { "5681.79d2ddf61465" }
569+
570+
it { is_expected.to be true }
571+
end
572+
573+
context "when the version contains embedded git commit with a delimiter and leading character" do
574+
# Example: https://github.com/jenkinsci/bom/releases/tag/5723.v6f9c6b_d1218a_
575+
let(:dependency_version) { "5723.v6f9c6b_d1218a_" }
576+
let(:comparison_version) { "5622.c9c3051619f5" }
577+
578+
it { is_expected.to be true }
579+
end
580+
581+
context "when only one of the version contains embedded git commits" do
582+
let(:dependency_version) { "5933.vcf06f7b_5d1a_2" }
583+
let(:comparison_version) { "5933" }
584+
585+
# it should not matter because the git SHA portion should be ignored for type matching
586+
it { is_expected.to be true }
587+
end
588+
589+
context "when version has pre-release qualifier with git SHA" do
590+
# Format: {number}.v{git-sha}-{qualifier}
591+
let(:dependency_version) { "252.v356d312df76f-beta" }
592+
let(:comparison_version) { "252.v456e423eg87g-beta" }
593+
594+
it { is_expected.to be true }
595+
end
596+
597+
context "when upgrading from pre-release to stable with git SHA" do
598+
let(:dependency_version) { "252.v356d312df76f-beta" }
599+
let(:comparison_version) { "252.v456e423eg87g" }
600+
601+
it { is_expected.to be true }
602+
end
603+
604+
context "when git SHA has maximum length (40 chars)" do
605+
let(:dependency_version) { "100.va1b2c3d4e5f6789012345678901234567890" }
606+
let(:comparison_version) { "200.vb2c3d4e5f67890123456789012345678901" }
607+
608+
it { is_expected.to be true }
609+
end
610+
611+
context "when git SHA has minimum length (7 chars)" do
612+
let(:dependency_version) { "100.va1b2c3d" }
613+
let(:comparison_version) { "200.vb2c3d4e" }
614+
615+
it { is_expected.to be true }
616+
end
617+
618+
context "when one version has git SHA and other is standard semver" do
619+
let(:dependency_version) { "1.2.3" }
620+
let(:comparison_version) { "1.2.4.va1b2c3d" }
621+
622+
# The sha portion should be ignored for type matching
623+
it { is_expected.to be true }
624+
end
625+
626+
context "when git SHA portion is invalid (too short)" do
627+
let(:dependency_version) { "100-vabc" }
628+
let(:comparison_version) { "200-vdef" }
629+
630+
# These should NOT be treated as git SHAs
631+
it { is_expected.to be false }
632+
end
633+
634+
context "when version has RC progression with git SHAs" do
635+
let(:dependency_version) { "100.va1b2c3d-rc1" }
636+
let(:comparison_version) { "100.ve5f6g7h-rc2" }
637+
638+
it { is_expected.to be true }
639+
end
640+
end
470641
end
471642
end
472643
end

0 commit comments

Comments
 (0)