Skip to content

Commit f4d7925

Browse files
committed
Add support for versions using git revision suffixes
1 parent f1aa0ad commit f4d7925

File tree

2 files changed

+329
-18
lines changed

2 files changed

+329
-18
lines changed

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

Lines changed: 158 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,39 +44,180 @@ 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,
85+
# as SHAs indicate specific build states rather than compatibility constraints.
86+
#
87+
# - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
88+
#
89+
# - No suffix: Both versions must have no suffix
90+
#
91+
# @example Java runtime compatibility
92+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
93+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
94+
# suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
95+
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
96+
#
97+
# @example Git SHA compatibility
98+
# suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
99+
# suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => true ( considered irrelevant for compatibility)
100+
#
101+
# @example Exact suffix matching
102+
# suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
103+
# suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
104+
# suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
105+
# suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
106+
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
107+
def suffix_compatible?(current, candidate)
108+
current_suffix = extract_version_suffix(current)
109+
candidate_suffix = extract_version_suffix(candidate)
70110

71111
if jre_or_jdk?(current_suffix) && jre_or_jdk?(candidate_suffix)
72112
return compatible_java_runtime?(T.must(current_suffix), T.must(candidate_suffix))
73113
end
74114

115+
return true if contains_git_sha?(current_suffix) || contains_git_sha?(candidate_suffix)
116+
75117
# If both versions share the exact suffix or no suffix, they are compatible
76118
current_suffix == candidate_suffix
77119
end
78120

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

81222
# Determines whether two Java runtime suffixes are compatible.
82223
#
@@ -182,8 +323,7 @@ def extract_version_suffix(version_string)
182323
# e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183324
return nil if suffix.match?(/^_?\d+$/)
184325

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

189329
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)