Skip to content

Commit 410ea63

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

File tree

2 files changed

+334
-18
lines changed

2 files changed

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

0 commit comments

Comments
 (0)