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"