From baddfa5a8015392d795f221f670726acb20f9ce3 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:14:36 +0100 Subject: [PATCH] [PR #11501/ed7ccbe3 backport][stable-12] maven_artifact: resolve SNAPSHOT to latest using snapshot metadata block (#11508) maven_artifact: resolve SNAPSHOT to latest using snapshot metadata block (#11501) * fix(maven_artifact): resolve SNAPSHOT to latest using snapshot metadata block Prefer the block (timestamp + buildNumber) from maven-metadata.xml which always points to the latest build, instead of scanning and returning on the first match. Repositories like GitHub Packages keep all historical entries in (oldest first), causing the module to resolve to the oldest snapshot instead of the latest. Fixes #5117 Fixes #11489 * fix(maven_artifact): address review feedback - Check both timestamp and buildNumber before using snapshot block, preventing IndexError when buildNumber is missing - Remove unreliable snapshotVersions scanning fallback; use literal -SNAPSHOT version for non-unique snapshot repos instead - Add tests for incomplete snapshot block and non-SNAPSHOT versions * fix(maven_artifact): restore snapshotVersions scanning with last-match Restore scanning as primary resolution (needed for per-extension accuracy per MNG-5459), but collect the last match instead of returning on the first. Fall back to block when no match is found, then to literal -SNAPSHOT version. * docs: update changelog fragment to match final implementation * fix(maven_artifact): use updated timestamp for snapshot resolution Use the attribute to select the newest snapshotVersion entry instead of relying on list order. This works independently of how the repository manager sorts entries in maven-metadata.xml. Also fix test docstring and update changelog fragment per reviewer feedback. * test(maven_artifact): shuffle entries to verify updated timestamp sorting Reorder snapshotVersion entries so the newest JAR is in the middle, not at the end. This ensures the test actually validates that resolution uses the timestamp rather than relying on list position. (cherry picked from commit ed7ccbe3d43a7afadfe72f2a31f9e777af548777) Co-authored-by: Adam R. --- ...117-maven-artifact-snapshot-resolution.yml | 2 + plugins/modules/maven_artifact.py | 26 ++- .../plugins/modules/test_maven_artifact.py | 163 ++++++++++++++++++ 3 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 changelogs/fragments/5117-maven-artifact-snapshot-resolution.yml diff --git a/changelogs/fragments/5117-maven-artifact-snapshot-resolution.yml b/changelogs/fragments/5117-maven-artifact-snapshot-resolution.yml new file mode 100644 index 0000000000..1e34bba16f --- /dev/null +++ b/changelogs/fragments/5117-maven-artifact-snapshot-resolution.yml @@ -0,0 +1,2 @@ +bugfixes: + - maven_artifact - fix SNAPSHOT version resolution to pick the newest matching ```` entry by ```` timestamp instead of the first. Repositories like GitHub Packages keep all historical entries in ```` (oldest first), causing the module to resolve to the oldest snapshot instead of the latest (https://github.com/ansible-collections/community.general/issues/5117, https://github.com/ansible-collections/community.general/issues/11489, https://github.com/ansible-collections/community.general/pull/11501). \ No newline at end of file diff --git a/plugins/modules/maven_artifact.py b/plugins/modules/maven_artifact.py index c10151ecfd..ef9f4620ee 100644 --- a/plugins/modules/maven_artifact.py +++ b/plugins/modules/maven_artifact.py @@ -465,17 +465,25 @@ class MavenDownloader: content = self._getContent(self.base + path, f"Failed to retrieve the maven metadata file: {path}") xml = etree.fromstring(content) - for snapshotArtifact in xml.xpath("/metadata/versioning/snapshotVersions/snapshotVersion"): - classifier = snapshotArtifact.xpath("classifier/text()") - artifact_classifier = classifier[0] if classifier else "" - extension = snapshotArtifact.xpath("extension/text()") - artifact_extension = extension[0] if extension else "" - if artifact_classifier == artifact.classifier and artifact_extension == artifact.extension: - return self._uri_for_artifact(artifact, snapshotArtifact.xpath("value/text()")[0]) + candidates = [] + for snapshot_artifact in xml.xpath("/metadata/versioning/snapshotVersions/snapshotVersion"): + classifier = snapshot_artifact.xpath("classifier/text()") + extension = snapshot_artifact.xpath("extension/text()") + if (classifier[0] if classifier else "") == artifact.classifier and ( + extension[0] if extension else "" + ) == artifact.extension: + value = snapshot_artifact.xpath("value/text()") + updated = snapshot_artifact.xpath("updated/text()") + if value: + candidates.append((updated[0] if updated else "", value[0])) + if candidates: + # updated is yyyymmddHHMMSS, so lexical max == newest + return self._uri_for_artifact(artifact, max(candidates, key=lambda item: item[0])[1]) timestamp_xmlpath = xml.xpath("/metadata/versioning/snapshot/timestamp/text()") - if timestamp_xmlpath: + build_number_xmlpath = xml.xpath("/metadata/versioning/snapshot/buildNumber/text()") + if timestamp_xmlpath and build_number_xmlpath: timestamp = timestamp_xmlpath[0] - build_number = xml.xpath("/metadata/versioning/snapshot/buildNumber/text()")[0] + build_number = build_number_xmlpath[0] return self._uri_for_artifact( artifact, artifact.version.replace("SNAPSHOT", f"{timestamp}-{build_number}") ) diff --git a/tests/unit/plugins/modules/test_maven_artifact.py b/tests/unit/plugins/modules/test_maven_artifact.py index c0ce2729ef..04f89b7c30 100644 --- a/tests/unit/plugins/modules/test_maven_artifact.py +++ b/tests/unit/plugins/modules/test_maven_artifact.py @@ -72,3 +72,166 @@ def test_find_version_by_spec(mocker, version_by_spec, version_choosed): mvn_downloader = maven_artifact.MavenDownloader(basic.AnsibleModule, "https://repo1.maven.org/maven2") assert mvn_downloader.find_version_by_spec(artifact) == version_choosed + + +# Metadata with multiple snapshotVersion entries per extension (as produced by GitHub Packages). +# The entries are deliberately NOT in chronological order to verify that +# resolution uses the timestamp rather than relying on list position. +snapshot_metadata_multiple_entries = b""" + + com.example + my-lib + 1.0.0-SNAPSHOT + + + 20260210.152345 + 3 + + 20260210153158 + + + jar + 1.0.0-20260205.091032-2 + 20260205091858 + + + jar + 1.0.0-20260210.152345-3 + 20260210153154 + + + pom + 1.0.0-20260210.152345-3 + 20260210153153 + + + jar + 1.0.0-20260203.123107-1 + 20260203123944 + + + pom + 1.0.0-20260203.123107-1 + 20260203123943 + + + pom + 1.0.0-20260205.091032-2 + 20260205091857 + + + + +""" + +# Metadata without a block but with only. +snapshot_metadata_no_snapshot_block = b""" + + com.example + my-lib + 1.0.0-SNAPSHOT + + 20260210153158 + + + jar + 1.0.0-20260203.123107-1 + 20260203123944 + + + pom + 1.0.0-20260203.123107-1 + 20260203123943 + + + + +""" + + +@pytest.mark.parametrize("patch_ansible_module", [None]) +def test_find_uri_for_snapshot_resolves_to_latest(mocker): + """When metadata has multiple snapshotVersion entries per extension, + the entry with the newest updated timestamp should be resolved.""" + _getContent = mocker.patch( + "ansible_collections.community.general.plugins.modules.maven_artifact.MavenDownloader._getContent" + ) + _getContent.return_value = snapshot_metadata_multiple_entries + + artifact = maven_artifact.Artifact("com.example", "my-lib", "1.0.0-SNAPSHOT", None, "", "jar") + mvn_downloader = maven_artifact.MavenDownloader(basic.AnsibleModule, "https://repo.example.com") + + uri = mvn_downloader.find_uri_for_artifact(artifact) + assert "1.0.0-20260210.152345-3.jar" in uri + + +@pytest.mark.parametrize("patch_ansible_module", [None]) +def test_find_uri_for_snapshot_without_snapshot_block_uses_snapshot_versions(mocker): + """When metadata lacks a block, fall back to scanning + entries.""" + _getContent = mocker.patch( + "ansible_collections.community.general.plugins.modules.maven_artifact.MavenDownloader._getContent" + ) + _getContent.return_value = snapshot_metadata_no_snapshot_block + + artifact = maven_artifact.Artifact("com.example", "my-lib", "1.0.0-SNAPSHOT", None, "", "jar") + mvn_downloader = maven_artifact.MavenDownloader(basic.AnsibleModule, "https://repo.example.com") + + uri = mvn_downloader.find_uri_for_artifact(artifact) + assert "1.0.0-20260203.123107-1.jar" in uri + + +# Metadata with a block that has but no . +# This is schema-valid but non-standard (e.g. produced by non-Maven tools). +# The module should fall back to scanning. +snapshot_metadata_incomplete_snapshot_block = b""" + + com.example + my-lib + 1.0.0-SNAPSHOT + + + 20260210.152345 + + 20260210153158 + + + jar + 1.0.0-20260210.152345-3 + 20260210153154 + + + pom + 1.0.0-20260210.152345-3 + 20260210153153 + + + + +""" + + +@pytest.mark.parametrize("patch_ansible_module", [None]) +def test_find_uri_for_snapshot_incomplete_snapshot_block_uses_snapshot_versions(mocker): + """When the block is incomplete (e.g. missing ), + fall back to instead of raising an error.""" + _getContent = mocker.patch( + "ansible_collections.community.general.plugins.modules.maven_artifact.MavenDownloader._getContent" + ) + _getContent.return_value = snapshot_metadata_incomplete_snapshot_block + + artifact = maven_artifact.Artifact("com.example", "my-lib", "1.0.0-SNAPSHOT", None, "", "jar") + mvn_downloader = maven_artifact.MavenDownloader(basic.AnsibleModule, "https://repo.example.com") + + uri = mvn_downloader.find_uri_for_artifact(artifact) + assert "1.0.0-20260210.152345-3.jar" in uri + + +@pytest.mark.parametrize("patch_ansible_module", [None]) +def test_find_uri_for_release_version_unaffected(mocker): + """Non-SNAPSHOT versions must not be affected by snapshot resolution logic.""" + artifact = maven_artifact.Artifact("com.example", "my-lib", "2.1.0", None, "", "jar") + mvn_downloader = maven_artifact.MavenDownloader(basic.AnsibleModule, "https://repo.example.com") + + uri = mvn_downloader.find_uri_for_artifact(artifact) + assert uri == "https://repo.example.com/com/example/my-lib/2.1.0/my-lib-2.1.0.jar"