Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 158 additions & 18 deletions maven/lib/dependabot/maven/shared/shared_version_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,39 +44,180 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder

MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i

# Minimum and maximum lengths for Git SHAs
MIN_GIT_SHA_LENGTH = 7
MAX_GIT_SHA_LENGTH = 40

# Regex for a valid Git SHA
# - Only hexadecimal characters (0-9, a-f)
# - Case-insensitive
# - At least one letter a-f to avoid purely numeric strings
GIT_COMMIT = T.let(
/\A(?=[0-9a-f]{#{MIN_GIT_SHA_LENGTH},#{MAX_GIT_SHA_LENGTH}}\z)(?=.*[a-f])/i,
Regexp
)

sig { params(comparison_version: Dependabot::Version).returns(T::Boolean) }
def matches_dependency_version_type?(comparison_version)
return true unless dependency.version

current_version_string = dependency.version
candidate_version_string = comparison_version.to_s
current = dependency.version
candidate = comparison_version.to_s

return true if pre_release_compatible?(current, candidate)

current_is_pre_release = current_version_string&.match?(MAVEN_PRE_RELEASE_QUALIFIERS)
candidate_is_pre_release = candidate_version_string.match?(MAVEN_PRE_RELEASE_QUALIFIERS)
return true if upgrade_to_stable?(current, candidate)

# Pre-releases are only compatible with other pre-releases
# When this happens, the suffix does not need to match exactly
# This allows transitions between 1.0.0-RC1 and 1.0.0-CR2, for example
return true if current_is_pre_release && candidate_is_pre_release
suffix_compatible?(current, candidate)
end

current_is_snapshot = current_version_string&.match?(MAVEN_SNAPSHOT_QUALIFIER)
# If the current version is a pre-release or a snapshot, allow upgrading to a stable release
# This can help move from pre-release to the stable version that supersedes it,
# but this should not happen vice versa as a stable release should not be downgraded to a pre-release
return true if (current_is_pre_release || current_is_snapshot) && !candidate_is_pre_release
private

current_suffix = extract_version_suffix(current_version_string)
candidate_suffix = extract_version_suffix(candidate_version_string)
# Determines whether two versions have compatible suffixes.
#
# Suffix compatibility is evaluated based on the type of suffix present:
#
# - Java runtime suffixes (JRE/JDK): Must have matching major versions and
# compatible runtime types (JRE can upgrade to JDK, but not vice versa)
#
# - Git commit SHAs: When any of the versions contain Git SHAs, they are considered irrelevant
# for compatibility purposes,
# as SHAs indicate specific build states rather than compatibility constraints.
#
# - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
#
# - No suffix: Both versions must have no suffix
#
# @example Java runtime compatibility
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
# suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
# suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
# suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
#
# @example Git SHA compatibility
# suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
# suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => true ( considered irrelevant for compatibility)
#
# @example Exact suffix matching
# suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
# suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
# suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
# suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
def suffix_compatible?(current, candidate)
current_suffix = extract_version_suffix(current)
candidate_suffix = extract_version_suffix(candidate)

if jre_or_jdk?(current_suffix) && jre_or_jdk?(candidate_suffix)
return compatible_java_runtime?(T.must(current_suffix), T.must(candidate_suffix))
end

return true if contains_git_sha?(current_suffix) || contains_git_sha?(candidate_suffix)

# If both versions share the exact suffix or no suffix, they are compatible
current_suffix == candidate_suffix
end

private
# Determines whether a given string is a valid Git commit SHA.
#
# Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
# Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
#
# @example Valid Git SHAs
# git_sha?("a1b2c3d") # => true (7-char short SHA)
# git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
# git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
# git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
#
# @example Invalid inputs
# git_sha?("1.2.3") # => false (version number)
# git_sha?("abc") # => false (too short, < 7 chars)
# git_sha?("ghijklm") # => false (invalid hex characters)
# git_sha?(nil) # => false (nil input)
sig { params(version: T.nilable(String)).returns(T::Boolean) }
def git_sha?(version)
return false unless version

normalized = version.start_with?("v") ? version[1..-1] : version
!!T.must(normalized).match?(GIT_COMMIT)
end

# Determines whether a version string contains a Git commit SHA.
#
# This method checks if any part of a version string (when split by common
# delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
# cases where delimiters within the SHA itself have been replaced with
# underscores or other characters.

# @example Standard delimiter-separated SHAs
# contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
# contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
# contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
#
# @example Embedded SHAs with modified delimiters
# contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
# contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
#
# @example Non-SHA versions
# contains_git_sha?("1.2.3") # => false (regular version)
# contains_git_sha?("abc") # => false (too short)
# contains_git_sha?(nil) # => false (nil input)
sig { params(version: T.nilable(String)).returns(T::Boolean) }
def contains_git_sha?(version)
return false unless version

# Check if any delimiter-separated part is a SHA
version.split(/[-._]/).any? { |part| git_sha?(part) } ||
# Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
git_sha?(version.gsub(/[-._]/, ""))
end

# Determines whether two versions are compatible based on pre-release status.
#
# Two versions are considered compatible if both are pre-release versions.
# This allows upgrades between different pre-release qualifiers of the same
# base version (e.g., RC1 → CR2, ALPHA → BETA)
#
# @example Compatible pre-release transitions
# pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
# pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
# pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
# pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
def pre_release_compatible?(current, candidate)
pre_release?(current) && pre_release?(candidate)
end

sig { params(version: T.nilable(String)).returns(T::Boolean) }
def pre_release?(version)
version&.match?(MAVEN_PRE_RELEASE_QUALIFIERS) || false
end

sig { params(version: T.nilable(String)).returns(T::Boolean) }
def snapshot?(version)
version&.match?(MAVEN_SNAPSHOT_QUALIFIER) || false
end

# This method allows upgrades from unstable versions (pre-releases or snapshots)
# to stable releases, which is a common and expected upgrade path.
# However, it prevents downgrades from stable releases back to pre-releases,
# as this would violate semantic versioning expectations.
#
# @example Valid upgrades to stable
# upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
# upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
# upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
# upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
#
# @example Invalid transitions (returns false)
# upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
# upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
# upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
# upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
sig { params(current: T.nilable(String), candidate: String).returns(T::Boolean) }
def upgrade_to_stable?(current, candidate)
(pre_release?(current) || snapshot?(current)) && !pre_release?(candidate)
end

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

# Must contain a hyphen to be considered a valid suffix
return suffix if suffix.include?("-") || suffix.include?("_")
return suffix if suffix.include?("-") || suffix.include?("_") || git_sha?(suffix)
end

nil
Expand Down
171 changes: 171 additions & 0 deletions maven/spec/dependabot/maven/shared/shared_version_finder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,177 @@
end
end
end

context "when the dependency version uses git commit for the delimiter" do
# Some tests are based on real-world examples from Jenkin's plugin release conventions
# See
# https://www.jenkins.io/doc/developer/publishing/releasing-cd/
# https://github.com/jenkinsci/jep/blob/master/jep/229/README.adoc

context "when the version contains embedded git commits" do
let(:dependency_version) { "6.2108.v08c2b_01b_cf4d" }
let(:comparison_version) { "6.2122.v70b_7b_f659d72" }

it { is_expected.to be true }
end

context "when the version has a single version with embedded git commit" do
let(:dependency_version) { "5622.c9c3051619f5" }
let(:comparison_version) { "5681.79d2ddf61465" }

it { is_expected.to be true }
end

context "when version has semantic version with git SHA and build number" do
# Format: {semver}-{build}.v{gitsha}
# Example from https://plugins.jenkins.io/caffeine-api/
let(:dependency_version) { "2.9.2-29.v717aac953ff3" }
let(:comparison_version) { "2.9.3-30.va1b2c3d4e5f6" }

it { is_expected.to be true }
end

context "when version has four-digit revision with git SHA" do
# Format: {revision}.v{gitsha}
# Example from credentials plugin
let(:dependency_version) { "1074.v60e6c29b_b_44b_" }
let(:comparison_version) { "1087.1089.v2f1b_9a_b_040e4" }

it { is_expected.to be true }
end

context "when version has multi-part revision with git SHA" do
# Format: {major}.{revision}.v{gitsha}
# Example from credentials plugin
let(:dependency_version) { "1087.1089.v2f1b_9a_b_040e4" }
let(:comparison_version) { "1087.v16065d268466" }

it { is_expected.to be true }
end
Comment on lines +509 to +516
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, what is expected to be true here? 1087.1089 > 1087 so either this is supposed to be false or the dep & compare versions are backwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"true" here means : "They are comparable"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see…but would suggest swapping the arguments for consistency with other test cases, all of which seem to be about offering upgrades.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consistency with other test cases

I think that this was just purely accidental because it was not an intention at all


context "when version has three-digit build with git SHA" do
# Format: {build}.v{gitsha}
# Example from jackson2-api plugin
let(:dependency_version) { "230.v59243c64b0a5" }
let(:comparison_version) { "246.va8a9f3eaf46a" }

it { is_expected.to be true }
end

context "when version has longer multi-part format" do
# Format: {major}.{minor}.{patch}.{build}.v{gitsha}
# Example from https://plugins.jenkins.io/aws-java-sdk/
let(:dependency_version) { "1.12.163-315.v2b_716ec8e4df" }
let(:comparison_version) { "1.12.170-320.v3c4d5e6f7g8h" }

it { is_expected.to be true }
end

context "when both versions have different delimiter styles in git SHA" do
# Both have git SHAs but different underscore patterns
let(:dependency_version) { "100.v60e6c29b_b_44b_" }
let(:comparison_version) { "105.va_b_018a_a_6b_0d3" }

it { is_expected.to be true }
end

context "when the version has a short git commit" do
let(:dependency_version) { "5622.c9c3051" }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW this would never apply to Jenkins CD, which always uses a 12-digit hash after v.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I tried to cover a mix of Jenkins use cases plus general usage for other libraries

let(:comparison_version) { "5681.c9c3051" }

it { is_expected.to be true }
end

context "when the version has a mix of short and long git commits" do
let(:dependency_version) { "5622.c9c3051" }
let(:comparison_version) { "5681.c9c3051619f5" }

it { is_expected.to be true }
end

context "when the version has a single embedded git commit using different delimiters" do
let(:dependency_version) { "5622-c9c3051619f5" }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also never used by the Jenkins project, FWIW

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as above, just more coverage of potential examples

let(:comparison_version) { "5681.79d2ddf61465" }

it { is_expected.to be true }
end

context "when the version has a single embedded git commit with the v suffix" do
# Example: https://github.com/jenkinsci/bom/releases/tag/5622.vc9c3051619f5
let(:dependency_version) { "5622.vc9c3051619f5" }
let(:comparison_version) { "5681.79d2ddf61465" }

it { is_expected.to be true }
end

context "when the version contains embedded git commit with a delimiter and leading character" do
# Example: https://github.com/jenkinsci/bom/releases/tag/5723.v6f9c6b_d1218a_
let(:dependency_version) { "5723.v6f9c6b_d1218a_" }
let(:comparison_version) { "5622.c9c3051619f5" }
Comment on lines +575 to +576
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this is a downgrade…?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what you mean with this

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5723 > 5622

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, what is the confusion/question here? What this test is asserting is that the two versions are "Semantically similar" to be compared. Later on during sorting is when it is decided if they are newer or not

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


it { is_expected.to be true }
end

context "when only one of the version contains embedded git commits" do
let(:dependency_version) { "5933.vcf06f7b_5d1a_2" }
let(:comparison_version) { "5933" }

# it should not matter because the git SHA portion should be ignored for type matching
it { is_expected.to be true }
end

context "when version has pre-release qualifier with git SHA" do
# Format: {number}.v{git-sha}-{qualifier}
let(:dependency_version) { "252.v356d312df76f-beta" }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never used in Jenkins FWIW

let(:comparison_version) { "252.v456e423eg87g-beta" }

it { is_expected.to be true }
end

context "when upgrading from pre-release to stable with git SHA" do
let(:dependency_version) { "252.v356d312df76f-beta" }
let(:comparison_version) { "252.v456e423eg87g" }

it { is_expected.to be true }
end

context "when git SHA has maximum length (40 chars)" do
let(:dependency_version) { "100.va1b2c3d4e5f6789012345678901234567890" }
let(:comparison_version) { "200.vb2c3d4e5f67890123456789012345678901" }

it { is_expected.to be true }
end

context "when git SHA has minimum length (7 chars)" do
let(:dependency_version) { "100.va1b2c3d" }
let(:comparison_version) { "200.vb2c3d4e" }

it { is_expected.to be true }
end

context "when one version has git SHA and other is standard semver" do
let(:dependency_version) { "1.2.3" }
let(:comparison_version) { "1.2.4.va1b2c3d" }

# The sha portion should be ignored for type matching
it { is_expected.to be true }
end

context "when git SHA portion is invalid (too short)" do
let(:dependency_version) { "100-vabc" }
let(:comparison_version) { "200-vdef" }

# These should NOT be treated as git SHAs
it { is_expected.to be false }
end

context "when version has RC progression with git SHAs" do
let(:dependency_version) { "100.va1b2c3d-rc1" }
let(:comparison_version) { "100.ve5f6g7h-rc2" }

it { is_expected.to be true }
end
end
end
end
end
Loading