From f422ff528d5db6e179e4757da432c673bc2510ab Mon Sep 17 00:00:00 2001 From: Nick Mancuso Date: Tue, 26 May 2026 14:31:43 -0400 Subject: [PATCH] Issue #276: Fix KPM fails to install SNAPSHOT version of plugins from Github packages --- kpm/lib/kpm/nexus_helper/github_api_calls.rb | 70 +++++-- kpm/spec/kpm/unit/github_api_calls_spec.rb | 210 +++++++++++++++++++ 2 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 kpm/spec/kpm/unit/github_api_calls_spec.rb diff --git a/kpm/lib/kpm/nexus_helper/github_api_calls.rb b/kpm/lib/kpm/nexus_helper/github_api_calls.rb index 383bdf5c..a9f1beff 100644 --- a/kpm/lib/kpm/nexus_helper/github_api_calls.rb +++ b/kpm/lib/kpm/nexus_helper/github_api_calls.rb @@ -9,15 +9,21 @@ module KPM module NexusFacade class GithubApiCalls < NexusApiCallsV2 def pull_artifact_endpoint(coordinates) - base_path, versioned_artifact, = build_base_path_and_coords(coordinates) - "#{base_path}/#{versioned_artifact}" + coords = parse_coordinates(coordinates) + resolved = resolve_snapshot_version(coordinates) + filename_version = resolved || coords[:version] + "#{artifact_base_path(coords)}/#{coords[:version]}/#{coords[:artifact_id]}-#{filename_version}.#{coords[:extension]}" end + alias parent_get_artifact_info get_artifact_info def get_artifact_info(coordinates) - super + coords = parse_coordinates(coordinates) + resolved = resolve_snapshot_version(coordinates) + filename_version = resolved || coords[:version] + + versioned_artifact = "#{coords[:version]}/#{coords[:artifact_id]}-#{filename_version}.#{coords[:extension]}" + sha1 = fetch_sha1(coordinates, versioned_artifact) - _, versioned_artifact, coords = build_base_path_and_coords(coordinates) - sha1 = get_sha1(coordinates) " true @@ -33,8 +39,13 @@ def get_artifact_info(coordinates) end def get_artifact_info_endpoint(coordinates) - base_path, = build_base_path_and_coords(coordinates) - "#{base_path}/maven-metadata.xml" + coords = parse_coordinates(coordinates) + base_path = artifact_base_path(coords) + if coords[:version] =~ /-SNAPSHOT$/ + "#{base_path}/#{coords[:version]}/maven-metadata.xml" + else + "#{base_path}/maven-metadata.xml" + end end def search_for_artifact_endpoint(_coordinates) @@ -47,23 +58,44 @@ def build_query_params(_coordinates, _what_parameters = nil) private - def get_sha1(coordinates) - base_path, versioned_artifact, = build_base_path_and_coords(coordinates) - endpoint = "#{base_path}/#{versioned_artifact}.sha1" - get_response_with_retries(coordinates, endpoint, nil) + # Resolves a SNAPSHOT version to its timestamped form using maven-metadata.xml. + # Returns nil for non-SNAPSHOT versions or when metadata is unavailable. + def resolve_snapshot_version(coordinates) + coords = parse_coordinates(coordinates) + return nil unless coords[:version] =~ /-SNAPSHOT$/ + + version_metadata = begin + parent_get_artifact_info(coordinates) + rescue StandardError + return nil + end + + doc = REXML::Document.new(version_metadata) + timestamp = begin + doc.elements['//versioning/snapshot/timestamp'].text + rescue StandardError + nil + end + build_number = begin + doc.elements['//versioning/snapshot/buildNumber'].text + rescue StandardError + nil + end + return nil if timestamp.nil? || build_number.nil? + + base_version = coords[:version].sub(/-SNAPSHOT$/, '') + "#{base_version}-#{timestamp}-#{build_number}" end - def build_base_path_and_coords(coordinates) + def fetch_sha1(coordinates, versioned_artifact) coords = parse_coordinates(coordinates) + endpoint = "#{artifact_base_path(coords)}/#{versioned_artifact}.sha1" + get_response_with_retries(coordinates, endpoint, nil) + end - # The url may contain the org and repo, e.g. 'https://maven.pkg.github.com/killbill/qualpay-java-client' + def artifact_base_path(coords) org_and_repo = URI.parse(configuration[:url]).path - - [ - "#{org_and_repo}/#{coords[:group_id].gsub('.', '/')}/#{coords[:artifact_id]}", - "#{coords[:version]}/#{coords[:artifact_id]}-#{coords[:version]}.#{coords[:extension]}", - coords - ] + "#{org_and_repo}/#{coords[:group_id].gsub('.', '/')}/#{coords[:artifact_id]}" end end end diff --git a/kpm/spec/kpm/unit/github_api_calls_spec.rb b/kpm/spec/kpm/unit/github_api_calls_spec.rb new file mode 100644 index 00000000..d439a99a --- /dev/null +++ b/kpm/spec/kpm/unit/github_api_calls_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rexml/document' + +describe KPM::NexusFacade::GithubApiCalls do + let(:logger) do + logger = ::Logger.new(STDOUT) + logger.level = Logger::FATAL + logger + end + let(:config) { { url: 'https://maven.pkg.github.com/myorg/my-repo', token: 'fake-token' } } + let(:nexus_remote) { described_class.new(config, true, logger) } + + let(:snapshot_coordinates) { 'com.example:my-plugin:jar:2.0.1-SNAPSHOT' } + let(:release_coordinates) { 'com.example:my-plugin:jar:1.0.0' } + + let(:snapshot_metadata_xml) do + <<~XML + + com.example + my-plugin + 2.0.1-SNAPSHOT + + + 20240520.203819 + 1 + + + + jar + 2.0.1-20240520.203819-1 + 20240520203819 + + + pom + 2.0.1-20240520.203819-1 + 20240520203819 + + + 20240520203819 + + + XML + end + + describe '#get_artifact_info_endpoint' do + it 'returns version-level metadata URL for SNAPSHOT versions' do + endpoint = nexus_remote.get_artifact_info_endpoint(snapshot_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/2.0.1-SNAPSHOT/maven-metadata.xml') + end + + it 'returns top-level metadata URL for release versions' do + endpoint = nexus_remote.get_artifact_info_endpoint(release_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/maven-metadata.xml') + end + end + + describe '#pull_artifact_endpoint' do + context 'with a SNAPSHOT version' do + before do + allow(nexus_remote).to receive(:parent_get_artifact_info) + .with(snapshot_coordinates) + .and_return(snapshot_metadata_xml) + end + + it 'uses SNAPSHOT directory with timestamped filename' do + endpoint = nexus_remote.pull_artifact_endpoint(snapshot_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/2.0.1-SNAPSHOT/my-plugin-2.0.1-20240520.203819-1.jar') + end + end + + context 'with a release version' do + it 'uses the literal version' do + endpoint = nexus_remote.pull_artifact_endpoint(release_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/1.0.0/my-plugin-1.0.0.jar') + end + end + + context 'when metadata fetch fails' do + before do + allow(nexus_remote).to receive(:parent_get_artifact_info) + .with(snapshot_coordinates) + .and_raise(StandardError, 'connection failed') + end + + it 'falls back to the literal SNAPSHOT version' do + endpoint = nexus_remote.pull_artifact_endpoint(snapshot_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/2.0.1-SNAPSHOT/my-plugin-2.0.1-SNAPSHOT.jar') + end + end + end + + describe '#get_artifact_info' do + let(:sha1_value) { 'abc123def456' } + + context 'with a SNAPSHOT version' do + before do + allow(nexus_remote).to receive(:parent_get_artifact_info) + .with(snapshot_coordinates) + .and_return(snapshot_metadata_xml) + allow(nexus_remote).to receive(:get_response_with_retries) + .and_return(sha1_value) + end + + it 'reports the original SNAPSHOT version' do + info = nexus_remote.get_artifact_info(snapshot_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//version'].text).to eq('2.0.1-SNAPSHOT') + end + + it 'marks the artifact as a snapshot' do + info = nexus_remote.get_artifact_info(snapshot_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//snapshot'].text).to eq('true') + end + + it 'uses SNAPSHOT directory with timestamped filename in repository path' do + info = nexus_remote.get_artifact_info(snapshot_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//repositoryPath'].text).to eq('/com/example/2.0.1-SNAPSHOT/my-plugin-2.0.1-20240520.203819-1.jar') + end + + it 'fetches sha1 using SNAPSHOT directory with timestamped filename' do + nexus_remote.get_artifact_info(snapshot_coordinates) + expect(nexus_remote).to have_received(:get_response_with_retries) + .with(snapshot_coordinates, '/myorg/my-repo/com/example/my-plugin/2.0.1-SNAPSHOT/my-plugin-2.0.1-20240520.203819-1.jar.sha1', nil) + end + end + + context 'with a release version' do + before do + allow(nexus_remote).to receive(:get_response_with_retries) + .and_return(sha1_value) + end + + it 'reports the release version' do + info = nexus_remote.get_artifact_info(release_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//version'].text).to eq('1.0.0') + end + + it 'marks the artifact as not a snapshot' do + info = nexus_remote.get_artifact_info(release_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//snapshot'].text).to eq('false') + end + + it 'uses the literal version in the repository path' do + info = nexus_remote.get_artifact_info(release_coordinates) + doc = REXML::Document.new(info) + expect(doc.elements['//repositoryPath'].text).to eq('/com/example/1.0.0/my-plugin-1.0.0.jar') + end + end + end + + describe '#search_for_artifact_endpoint' do + it 'raises NoMethodError' do + expect { nexus_remote.search_for_artifact_endpoint(release_coordinates) } + .to raise_exception(NoMethodError, 'GitHub Packages has no search support') + end + end + + describe 'SNAPSHOT with multiple builds' do + let(:metadata_with_multiple_builds) do + <<~XML + + com.example + my-plugin + 2.0.1-SNAPSHOT + + + 20240521.143000 + 3 + + + + jar + 2.0.1-20240519.100000-1 + 20240519100000 + + + jar + 2.0.1-20240520.120000-2 + 20240520120000 + + + jar + 2.0.1-20240521.143000-3 + 20240521143000 + + + 20240521143000 + + + XML + end + + before do + allow(nexus_remote).to receive(:parent_get_artifact_info) + .with(snapshot_coordinates) + .and_return(metadata_with_multiple_builds) + end + + it 'uses the latest build from the snapshot element, not the first snapshotVersion' do + endpoint = nexus_remote.pull_artifact_endpoint(snapshot_coordinates) + expect(endpoint).to eq('/myorg/my-repo/com/example/my-plugin/2.0.1-SNAPSHOT/my-plugin-2.0.1-20240521.143000-3.jar') + end + end +end