Skip to content

Commit cab9a6a

Browse files
committed
Add support for versions using git revision suffixes
1 parent e37c650 commit cab9a6a

File tree

2 files changed

+332
-18
lines changed

2 files changed

+332
-18
lines changed

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

Lines changed: 148 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,190 @@
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 comparing standard semver to incremental format" do
537+
# One uses standard semver, the other uses JEP-229
538+
let(:dependency_version) { "2.6.1" }
539+
let(:comparison_version) { "1087.v16065d268466" }
540+
541+
it { is_expected.to be false }
542+
end
543+
544+
context "when both versions have different delimiter styles in git SHA" do
545+
# Both have git SHAs but different underscore patterns
546+
let(:dependency_version) { "100.v60e6c29b_b_44b_" }
547+
let(:comparison_version) { "105.va_b_018a_a_6b_0d3" }
548+
549+
it { is_expected.to be true }
550+
end
551+
552+
context "when the version has a short git commit" do
553+
let(:dependency_version) { "5622.c9c3051" }
554+
let(:comparison_version) { "5681.c9c3051" }
555+
556+
it { is_expected.to be true }
557+
end
558+
559+
context "when the version has a mix of short and long git commits" do
560+
let(:dependency_version) { "5622.c9c3051" }
561+
let(:comparison_version) { "5681.c9c3051619f5" }
562+
563+
it { is_expected.to be true }
564+
end
565+
566+
context "when the version has a single embedded git commit using different delimiters" do
567+
let(:dependency_version) { "5622-c9c3051619f5" }
568+
let(:comparison_version) { "5681.79d2ddf61465" }
569+
570+
it { is_expected.to be true }
571+
end
572+
573+
context "when the version has a single embedded git commit with the v suffix" do
574+
# Example: https://github.com/jenkinsci/bom/releases/tag/5622.vc9c3051619f5
575+
let(:dependency_version) { "5622.vc9c3051619f5" }
576+
let(:comparison_version) { "5681.79d2ddf61465" }
577+
578+
it { is_expected.to be true }
579+
end
580+
581+
context "when the version contains embedded git commit with a delimiter and leading character" do
582+
# Example: https://github.com/jenkinsci/bom/releases/tag/5723.v6f9c6b_d1218a_
583+
let(:dependency_version) { "5723.v6f9c6b_d1218a_" }
584+
let(:comparison_version) { "5622.c9c3051619f5" }
585+
586+
it { is_expected.to be true }
587+
end
588+
589+
context "when only one of the version contains embedded git commits" do
590+
let(:dependency_version) { "5933.vcf06f7b_5d1a_2" }
591+
let(:comparison_version) { "5933" }
592+
593+
it { is_expected.to be false }
594+
end
595+
596+
context "when version has pre-release qualifier with git SHA" do
597+
# Format: {number}.v{git-sha}-{qualifier}
598+
let(:dependency_version) { "252.v356d312df76f-beta" }
599+
let(:comparison_version) { "252.v456e423eg87g-beta" }
600+
601+
it { is_expected.to be true }
602+
end
603+
604+
context "when upgrading from pre-release to stable with git SHA" do
605+
let(:dependency_version) { "252.v356d312df76f-beta" }
606+
let(:comparison_version) { "252.v456e423eg87g" }
607+
608+
it { is_expected.to be true }
609+
end
610+
611+
context "when downgrading from stable to pre-release with git SHA" do
612+
let(:dependency_version) { "252.v456e423eg87g" }
613+
let(:comparison_version) { "252.v356d312df76f-beta" }
614+
615+
it { is_expected.to be false }
616+
end
617+
618+
context "when git SHA has maximum length (40 chars)" do
619+
let(:dependency_version) { "100.va1b2c3d4e5f6789012345678901234567890" }
620+
let(:comparison_version) { "200.vb2c3d4e5f67890123456789012345678901" }
621+
622+
it { is_expected.to be true }
623+
end
624+
625+
context "when git SHA has minimum length (7 chars)" do
626+
let(:dependency_version) { "100.va1b2c3d" }
627+
let(:comparison_version) { "200.vb2c3d4e" }
628+
629+
it { is_expected.to be true }
630+
end
631+
632+
context "when one version has git SHA and other is standard semver" do
633+
let(:dependency_version) { "1.2.3" }
634+
let(:comparison_version) { "1.2.4.va1b2c3d" }
635+
636+
it { is_expected.to be false }
637+
end
638+
639+
context "when git SHA portion is invalid (too short)" do
640+
let(:dependency_version) { "100-vabc" }
641+
let(:comparison_version) { "200-vdef" }
642+
643+
# These should NOT be treated as git SHAs
644+
it { is_expected.to be false }
645+
end
646+
647+
context "when version has RC progression with git SHAs" do
648+
let(:dependency_version) { "100.va1b2c3d-rc1" }
649+
let(:comparison_version) { "100.ve5f6g7h-rc2" }
650+
651+
it { is_expected.to be true }
652+
end
653+
end
470654
end
471655
end
472656
end

0 commit comments

Comments
 (0)