You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/06/04 14:55:13 UTC

(tooling-trusted-releases) 02/03: #1298, #1297 - Update downloads URLs to follow foundation policy (after /atr SVN location is gone)

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit dcd3dc91696e6450d91e40d5d1a969938b8f771a
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jun 4 11:20:29 2026 +0100

    #1298, #1297 - Update downloads URLs to follow foundation policy (after /atr SVN location is gone)
---
 atr/api/__init__.py                             |  4 +-
 atr/constants.py                                |  1 +
 atr/construct.py                                |  4 +-
 atr/get/announce.py                             |  3 +-
 atr/get/catalog.py                              | 52 +++++++++++++++++------
 atr/models/sql.py                               |  3 ++
 atr/paths.py                                    | 39 +++++++++++++-----
 atr/post/keys.py                                |  8 ++--
 atr/storage/writers/announce.py                 | 10 ++++-
 atr/templates/_catalog_version.html             |  6 +--
 atr/util.py                                     | 55 ++++++++++++++++---------
 migrations/versions/0093_2026.06.04_40cbca79.py | 27 ++++++++++++
 tests/unit/test_catalog.py                      |  6 +--
 tests/unit/test_svn_publish_helpers.py          | 47 +++++++++++++++++++--
 14 files changed, 202 insertions(+), 63 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index bdcb5fa5..bda6b984 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1477,7 +1477,9 @@ async def signature_provenance(
         signing_keys.append(
             models.api.SignatureProvenanceKey(
                 committee=committee.key,
-                keys_file_url=f"{paths.committee_downloads_url(host, committee)}/KEYS",
+                keys_file_url=util.public_download_url(
+                    committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host
+                ),
                 keys_file_sha3_256=keys_file_sha3_256,
             )
         )
diff --git a/atr/constants.py b/atr/constants.py
index 50780127..869cf0e9 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -17,6 +17,7 @@
 
 from typing import Final
 
+CLOSER_LUA_URL: Final[str] = "https://www.apache.org/dyn/closer.lua"
 DOWNLOADS_APACHE_URL: Final[str] = "https://downloads.apache.org"
 SVN_DIST_PUBLIC_URL: Final[str] = "https://dist.apache.org/repos/dist/atr"
 SVN_DIST_ROOT_URL: Final[str] = "https://dist.apache.org/repos/dist"
diff --git a/atr/construct.py b/atr/construct.py
index 1c300e43..52672081 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -193,7 +193,7 @@ async def announce_release_subject_and_body(
 
     project = release.project
     project_display_name = project.short_display_name if project else str(options.project_key)
-    download_url = paths.committee_downloads_dist_url(committee, host)
+    download_url = util.public_download_url(committee, None, util.DownloadFile.METADATA, host=host)
     if options.download_path_suffix is not None:
         download_url += f"/{options.download_path_suffix!s}"
     download_url += "/"
@@ -333,7 +333,7 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
     keys_file = None
     keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
     if await aiofiles.os.path.isfile(keys_file_path):
-        keys_file = f"{paths.committee_downloads_url(host, committee)}/KEYS"
+        keys_file = util.public_download_url(committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host)
 
     checklist_content = ""
     async with db.session() as data:
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 92c4e0b5..f7be6555 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -29,7 +29,6 @@ import atr.get.projects as projects
 import atr.htm as htm
 import atr.models.safe as safe
 import atr.models.sql as sql
-import atr.paths as paths
 import atr.post as post
 import atr.render as render
 import atr.shared as shared
@@ -91,7 +90,7 @@ async def selected(
         default_subject_template, default_body_template, options
     )
 
-    description_download_prefix = paths.committee_downloads_dist_url(committee, config.get().APP_HOST)
+    description_download_prefix = util.public_download_url(committee, None, util.DownloadFile.METADATA)
     preview_url = util.as_url(
         post.announce.preview,
         project_key=release.project.key,
diff --git a/atr/get/catalog.py b/atr/get/catalog.py
index b66f5c72..a470ed8a 100644
--- a/atr/get/catalog.py
+++ b/atr/get/catalog.py
@@ -50,7 +50,7 @@ async def project(_session: web.Public, _catalog: Literal["catalog"], project_ke
 
     cycles_by_key = {cycle.cycle_key: cycle for cycle in project_cycles}
     now = datetime.datetime.now(datetime.UTC)
-    versions = _catalog_versions(artifacts, cycles_by_key)
+    versions = _catalog_versions(artifacts, cycles_by_key, project_obj.committee)
     grouped = _grouped_layout(project_obj.version_method)
     cycles = _catalog_cycles(versions, now) if grouped else []
 
@@ -74,6 +74,11 @@ class _CatalogArtifact:
     key_fingerprint: str | None
     svn_revision: int | None
     downloadable: bool
+    # Public download URLs: the artifact via the mirror network, its signature and
+    # checksum from downloads.apache.org. None when the version is not downloadable.
+    artifact_url: str | None
+    signature_url: str | None
+    checksum_url: str | None
 
 
 @dataclasses.dataclass
@@ -98,9 +103,41 @@ def _grouped_layout(version_method: sql.VersionMethod) -> bool:
     return version_method in _GROUPED_METHODS
 
 
+def _catalog_artifact(row: sql.Artifact, committee: sql.Committee | None, downloadable: bool) -> _CatalogArtifact:
+    suffix = safe.RelPath(row.download_path_suffix) if row.download_path_suffix else None
+    artifact_url: str | None = None
+    signature_url: str | None = None
+    checksum_url: str | None = None
+    if downloadable and (committee is not None):
+        artifact_url = util.public_download_url(
+            committee, suffix, util.DownloadFile.ARTIFACT, filename=row.artifact_path
+        )
+        if row.signature_path:
+            signature_url = util.public_download_url(
+                committee, suffix, util.DownloadFile.METADATA, filename=row.signature_path
+            )
+        if row.checksum_path:
+            checksum_url = util.public_download_url(
+                committee, suffix, util.DownloadFile.METADATA, filename=row.checksum_path
+            )
+    return _CatalogArtifact(
+        artifact_path=row.artifact_path,
+        classification=row.classification,
+        signature_path=row.signature_path,
+        checksum_path=row.checksum_path,
+        key_fingerprint=row.key_fingerprint,
+        svn_revision=row.svn_revision,
+        downloadable=downloadable,
+        artifact_url=artifact_url,
+        signature_url=signature_url,
+        checksum_url=checksum_url,
+    )
+
+
 def _catalog_versions(
     artifacts: Sequence[sql.Artifact],
     cycles_by_key: dict[str, sql.ProjectCycle],
+    committee: sql.Committee | None,
 ) -> list[_CatalogVersion]:
     by_version: dict[str, list[sql.Artifact]] = collections.defaultdict(list)
     for artifact in artifacts:
@@ -121,18 +158,7 @@ def _catalog_versions(
                 released=(release.released or release.created) if (release is not None) else None,
                 svn_revision=max(svn_revisions) if svn_revisions else None,
                 cycle=cycle,
-                artifacts=[
-                    _CatalogArtifact(
-                        artifact_path=row.artifact_path,
-                        classification=row.classification,
-                        signature_path=row.signature_path,
-                        checksum_path=row.checksum_path,
-                        key_fingerprint=row.key_fingerprint,
-                        svn_revision=row.svn_revision,
-                        downloadable=downloadable,
-                    )
-                    for row in rows
-                ],
+                artifacts=[_catalog_artifact(row, committee, downloadable) for row in rows],
             )
         )
 
diff --git a/atr/models/sql.py b/atr/models/sql.py
index e4db671e..0124059c 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1878,6 +1878,9 @@ class Artifact(sqlmodel.SQLModel, table=True):
     classification: str | None = sqlmodel.Field(default=None, **example("source"))
     # Per-artifact SVN revision - not all artifacts in a release are added in the same commit
     svn_revision: int | None = sqlmodel.Field(default=None, index=True, **example(12345))
+    # The release's path under the committee downloads dir, needed to build the public
+    # download URLs. NULL means the files sit directly under the committee dir.
+    download_path_suffix: str | None = sqlmodel.Field(default=None, **example("example/0.0.1"))
 
     # M-1: Artifact -> Release
     # 1-M: Release -> [Artifact]
diff --git a/atr/paths.py b/atr/paths.py
index 90798e7b..7e4ac812 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -16,7 +16,6 @@
 # under the License.
 
 import pathlib
-import urllib.parse
 
 import atr.config as config
 import atr.constants as constants
@@ -30,6 +29,27 @@ def base_path_for_revision(
     return get_unfinished_dir() / project_key / version_key / revision
 
 
+def closer_download_url(relpath: safe.RelPath) -> str:
+    # For the artifacts themselves we go via the mirror network rather than
+    # downloads.apache.org. action=download skips the human mirror-picker page and
+    # redirects straight to a chosen mirror.
+    return f"{constants.CLOSER_LUA_URL}/{relpath}?action=download"
+
+
+def committee_dist_relpath(
+    committee: sql.Committee, suffix: safe.RelPath | None = None, filename: str | None = None
+) -> safe.RelPath:
+    # The committee's path relative to the downloads root, ie the shared prefix that
+    # the download hosts and the distribution SVN area both hang a release off.
+    # Optionally extended with the per-release download suffix and a file name.
+    relpath = safe.RelPath.from_path(committee_downloads_dir(committee).path.relative_to(get_downloads_dir().path))
+    if suffix is not None:
+        relpath = relpath.append(suffix.as_path())
+    if filename is not None:
+        relpath = relpath.append(filename)
+    return relpath
+
+
 def committee_downloads_dir(committee: sql.Committee) -> safe.StatePath:
     downloads_dir = get_downloads_dir()
     if committee.is_podling:
@@ -37,16 +57,6 @@ def committee_downloads_dir(committee: sql.Committee) -> safe.StatePath:
     return downloads_dir / committee.key
 
 
-def committee_downloads_dist_url(committee: sql.Committee, host: str) -> str:
-    if config.get().SVN_PUBLISH_URL:
-        relpath = committee_downloads_dir(committee).path.relative_to(get_downloads_dir().path)
-        public_path = urllib.parse.urlparse(constants.SVN_DIST_PUBLIC_URL).path.rstrip("/")
-        if public_path.endswith("/release"):
-            return f"{constants.DOWNLOADS_APACHE_URL}/{relpath}"
-        return f"{constants.SVN_DIST_PUBLIC_URL.rstrip('/')}/{relpath}"
-    return committee_downloads_url(host, committee)
-
-
 def committee_downloads_url(host: str, committee: sql.Committee) -> str:
     # This is a slight extension of the intended paths concept
     # But URLs contain paths, so atr.paths does not have to be limited to filesystem paths
@@ -55,6 +65,13 @@ def committee_downloads_url(host: str, committee: sql.Committee) -> str:
     return f"https://{host}/downloads/{committee.key}"
 
 
+def downloads_url(relpath: safe.RelPath) -> str:
+    # downloads.apache.org itself, not a mirror. Signatures, checksums and KEYS
+    # must come from here rather than a mirror, otherwise a bad mirror could serve
+    # a matching artifact and signature.
+    return f"{constants.DOWNLOADS_APACHE_URL}/{relpath}"
+
+
 def get_archives_dir() -> safe.StatePath:
     return safe.StatePath(pathlib.Path(config.get().ARCHIVES_STORAGE_DIR))
 
diff --git a/atr/post/keys.py b/atr/post/keys.py
index e18647a7..58f2b30d 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -27,7 +27,6 @@ import quart
 
 import atr.blueprints.post as post
 import atr.config as config
-import atr.constants as constants
 import atr.db as db
 import atr.form as form
 import atr.get as get
@@ -36,6 +35,7 @@ import atr.log as log
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.unsafe as unsafe
+import atr.paths as paths
 import atr.sessions as sessions
 import atr.shared as shared
 import atr.storage as storage
@@ -269,9 +269,9 @@ async def _add_key_text_resolve(session: web.Committer, add_form: shared.keys.Ad
 
 
 def _construct_keys_url(committee_key: str, *, is_podling: bool) -> str:
-    if is_podling:
-        return f"{constants.DOWNLOADS_APACHE_URL}/incubator/{committee_key}/KEYS"
-    return f"{constants.DOWNLOADS_APACHE_URL}/{committee_key}/KEYS"
+    # KEYS comes from downloads.apache.org, not a mirror.
+    prefix = "incubator/" if is_podling else ""
+    return paths.downloads_url(safe.RelPath(f"{prefix}{committee_key}/KEYS"))
 
 
 async def _delete_openpgp_key(
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index a5db7405..045a3372 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -222,7 +222,9 @@ class CommitteeMember(CommitteeParticipant):
                     )
             try:
                 target = util.svn_publish_target()
-                public_url = util.svn_publish_public_url(target, committee, effective_download_path_suffix)
+                public_url = util.public_download_url(
+                    committee, effective_download_path_suffix, util.DownloadFile.METADATA
+                )
             except ValueError as exc:
                 log.warning(f"SVN publication target is not configured for {project_key!s} {version_key!s}: {exc}")
             else:
@@ -292,7 +294,9 @@ class CommitteeMember(CommitteeParticipant):
 
             # Record the artifacts before promoting, since promotion deletes the
             # revisions and cascade-deletes the file state we read classifications from
-            await self.__write_artifact_rows(release, finished_path, preview_revision_number, published_revision)
+            await self.__write_artifact_rows(
+                release, finished_path, preview_revision_number, published_revision, effective_download_path_suffix
+            )
             await self.__promote_in_database(release, preview_revision_number, release_date)
             self.__data.add(
                 sql.LifecycleEvent(
@@ -464,6 +468,7 @@ class CommitteeMember(CommitteeParticipant):
         finished_path: safe.StatePath,
         revision_number: safe.RevisionNumber,
         svn_revision: int | None,
+        download_path_suffix: safe.RelPath | None,
     ) -> None:
         revision_seq = int(str(revision_number))
         # The preview revision created when a vote passes has no checks of its own, so the
@@ -498,5 +503,6 @@ class CommitteeMember(CommitteeParticipant):
                     checksum_path=checksum_path,
                     classification=classifications.get(rel),
                     svn_revision=svn_revision,
+                    download_path_suffix=str(download_path_suffix) if download_path_suffix is not None else None,
                 )
             )
diff --git a/atr/templates/_catalog_version.html b/atr/templates/_catalog_version.html
index fcdef084..e7ceb4a3 100644
--- a/atr/templates/_catalog_version.html
+++ b/atr/templates/_catalog_version.html
@@ -21,9 +21,9 @@
           </span>
           <span class="small text-nowrap">
             {% if artifact.downloadable %}
-              <a href="{{ as_url(get.download.path, project_key=project.key, version_key=version.version, file_path=artifact.artifact_path) }}">Download</a>
-              {% if artifact.signature_path %}<a class="ms-2" href="{{ as_url(get.download.path, project_key=project.key, version_key=version.version, file_path=artifact.signature_path) }}">.asc</a>{% endif %}
-              {% if artifact.checksum_path %}<a class="ms-2" href="{{ as_url(get.download.path, project_key=project.key, version_key=version.version, file_path=artifact.checksum_path) }}">checksum</a>{% endif %}
+              <a href="{{ artifact.artifact_url }}">Download</a>
+              {% if artifact.signature_url %}<a class="ms-2" href="{{ artifact.signature_url }}">.asc</a>{% endif %}
+              {% if artifact.checksum_url %}<a class="ms-2" href="{{ artifact.checksum_url }}">checksum</a>{% endif %}
             {% else %}
               <span class="text-secondary">Metadata only</span>
             {% endif %}
diff --git a/atr/util.py b/atr/util.py
index 7ddc566e..f8f079cc 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -172,6 +172,13 @@ class SvnPublishTarget(enum.Enum):
     RELEASE = "release"
 
 
+class DownloadFile(enum.Enum):
+    # Ultimately decides where a published release file should be fetched from:
+    # artifacts via the mirror network, metadata from the downloads.a.o.
+    ARTIFACT = "artifact"
+    METADATA = "metadata"
+
+
 class EmailRecipients(Protocol):
     email_to: str
     email_cc: list[str]
@@ -993,6 +1000,34 @@ def plural(count: int, singular: str, plural_form: str | None = None, *, include
     return word
 
 
+def public_download_url(
+    committee: sql.Committee,
+    suffix: safe.RelPath | None,
+    kind: DownloadFile,
+    filename: str | None = None,
+    host: str | None = None,
+) -> str:
+    # The single place that resolves where a release file is fetched from, by
+    # lifecycle (beta vs released) and role (artifact vs metadata).
+    if not config.get().SVN_PUBLISH_URL:
+        # No SVN publishing configured (dev), so ATR serves the downloads itself.
+        url = paths.committee_downloads_url(host or config.get().APP_HOST, committee)
+        if suffix is not None:
+            url = f"{url}/{suffix}"
+        if filename is not None:
+            url = f"{url}/{filename}"
+        return url
+    relpath = paths.committee_dist_relpath(committee, suffix, filename)
+    match svn_publish_target():
+        case SvnPublishTarget.ATR:
+            # Beta: files are still in ATR's area of the distribution SVN, not yet mirrored.
+            return f"{constants.SVN_DIST_PUBLIC_URL.rstrip('/')}/{relpath}"
+        case SvnPublishTarget.RELEASE:
+            if kind is DownloadFile.ARTIFACT:
+                return paths.closer_download_url(relpath)
+            return paths.downloads_url(relpath)
+
+
 async def read_file_for_viewer(full_path: safe.StatePath, max_size: int) -> tuple[str | None, bool, bool, str | None]:
     """Read file content for viewer."""
     content: str | None = None
@@ -1084,28 +1119,10 @@ def svn_publish_internal_url(
     publish_url = config.get().SVN_PUBLISH_URL
     if not publish_url:
         raise ValueError("SVN_PUBLISH_URL is not configured")
-    relpath = paths.committee_downloads_dir(committee).path.relative_to(paths.get_downloads_dir().path)
-    if download_path_suffix is not None:
-        relpath = relpath / download_path_suffix.as_path()
+    relpath = paths.committee_dist_relpath(committee, download_path_suffix)
     return f"{publish_url.rstrip('/')}/{relpath}"
 
 
-def svn_publish_public_url(
-    target: SvnPublishTarget,
-    committee: sql.Committee,
-    download_path_suffix: safe.RelPath | None,
-) -> str:
-    relpath = paths.committee_downloads_dir(committee).path.relative_to(paths.get_downloads_dir().path)
-    if download_path_suffix is not None:
-        relpath = relpath / download_path_suffix.as_path()
-    match target:
-        case SvnPublishTarget.RELEASE:
-            base_url = constants.DOWNLOADS_APACHE_URL
-        case SvnPublishTarget.ATR:
-            base_url = constants.SVN_DIST_PUBLIC_URL
-    return f"{base_url.rstrip('/')}/{relpath}"
-
-
 def svn_publish_target() -> SvnPublishTarget:
     public_path = urllib.parse.urlparse(constants.SVN_DIST_PUBLIC_URL).path.rstrip("/")
     if public_path.endswith("/atr"):
diff --git a/migrations/versions/0093_2026.06.04_40cbca79.py b/migrations/versions/0093_2026.06.04_40cbca79.py
new file mode 100644
index 00000000..be8bc23c
--- /dev/null
+++ b/migrations/versions/0093_2026.06.04_40cbca79.py
@@ -0,0 +1,27 @@
+"""Add download_path_suffix to artifacts
+
+Revision ID: 0093_2026.06.04_40cbca79
+Revises: 0092_2026.06.02_87d3e8f1
+Create Date: 2026-06-04 08:53:16.500003+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0093_2026.06.04_40cbca79"
+down_revision: str | None = "0092_2026.06.02_87d3e8f1"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("download_path_suffix", sa.String(), nullable=True))
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.drop_column("download_path_suffix")
diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py
index 26ebb2c9..8b90c942 100644
--- a/tests/unit/test_catalog.py
+++ b/tests/unit/test_catalog.py
@@ -108,7 +108,7 @@ def test_status_reflects_release_and_archive_state() -> None:
         _artifact(project, "4.1.7", "a-4.1.7.tar.gz", release=archived),
     ]
 
-    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {})}
+    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {}, project.committee_key)}
 
     assert versions["5.0.2"].status == "released"
     assert versions["4.1.7"].status == "archived"
@@ -123,7 +123,7 @@ def test_only_released_versions_are_downloadable() -> None:
         _artifact(project, "4.1.7", "a-4.1.7.tar.gz", release=archived, svn_revision=28114),
     ]
 
-    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {})}
+    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {}, project.committee_key)}
 
     assert versions["5.0.2"].artifacts[0].downloadable is True
     assert versions["4.1.7"].artifacts[0].downloadable is False
@@ -143,7 +143,7 @@ def test_versions_are_newest_first() -> None:
         _artifact(project, "5.0.2", "a-5.0.2.tar.gz", release=newer),
     ]
 
-    versions = catalog._catalog_versions(artifacts, {})
+    versions = catalog._catalog_versions(artifacts, {}, project.committee_key)
 
     assert [v.version for v in versions] == ["5.0.2", "5.0.1"]
 
diff --git a/tests/unit/test_svn_publish_helpers.py b/tests/unit/test_svn_publish_helpers.py
index 76d79af2..85a7d1f2 100644
--- a/tests/unit/test_svn_publish_helpers.py
+++ b/tests/unit/test_svn_publish_helpers.py
@@ -119,12 +119,53 @@ def test_svn_publish_models_round_trip() -> None:
     assert sql.TaskType.SVN_PUBLISH.label == "SVN publish"
 
 
-def test_svn_publish_public_url() -> None:
+def test_public_download_url_uses_svn_dist_area_for_beta_target(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://internal.example.invalid/repos/dist/atr")
+    monkeypatch.setattr(constants, "SVN_DIST_PUBLIC_URL", "https://dist.apache.org/repos/dist/atr")
+    committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+    url = util.public_download_url(committee, safe.RelPath("apple-1.0"), util.DownloadFile.METADATA)
+
+    assert url == "https://dist.apache.org/repos/dist/atr/apple/apple-1.0"
+
+
+def test_public_download_url_uses_mirror_for_released_artifact(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://internal.example.invalid/repos/dist/release")
+    monkeypatch.setattr(constants, "SVN_DIST_PUBLIC_URL", "https://dist.apache.org/repos/dist/release")
+    committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+    url = util.public_download_url(
+        committee, safe.RelPath("apple-1.0"), util.DownloadFile.ARTIFACT, filename="apple-1.0.tar.gz"
+    )
+
+    assert url == f"{constants.CLOSER_LUA_URL}/apple/apple-1.0/apple-1.0.tar.gz?action=download"
+
+
+def test_public_download_url_uses_canonical_host_for_released_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://internal.example.invalid/repos/dist/release")
+    monkeypatch.setattr(constants, "SVN_DIST_PUBLIC_URL", "https://dist.apache.org/repos/dist/release")
     committee = sql.Committee(key="apple", name="Apple", is_podling=False)
 
-    target_url = util.svn_publish_public_url(util.SvnPublishTarget.ATR, committee, safe.RelPath("apple-1.0"))
+    url = util.public_download_url(
+        committee, safe.RelPath("apple-1.0"), util.DownloadFile.METADATA, filename="apple-1.0.tar.gz.asc"
+    )
+
+    assert url == f"{constants.DOWNLOADS_APACHE_URL}/apple/apple-1.0/apple-1.0.tar.gz.asc"
+
+
+def test_public_download_url_falls_back_to_self_host_without_svn(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", None)
+    committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+    url = util.public_download_url(
+        committee,
+        safe.RelPath("apple-1.0"),
+        util.DownloadFile.ARTIFACT,
+        filename="apple-1.0.tar.gz",
+        host="atr.example",
+    )
 
-    assert target_url == f"{constants.SVN_DIST_PUBLIC_URL}/apple/apple-1.0"
+    assert url == "https://atr.example/downloads/apple/apple-1.0/apple-1.0.tar.gz"
 
 
 def test_svn_publish_target_from_public_url(monkeypatch: pytest.MonkeyPatch) -> None:


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org