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/08/11 09:26:02 UTC

(tooling-trusted-releases) branch arm updated (09fcf067 -> 5a24945d)

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

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


    from 09fcf067 Update cordova config in dist watcher
     add 125834d0 Avoid false positives about secrets from CodeQL
     add d221aa18 Add a helper for exporting from SVN
     add c37fd679 Add code to compare two trees
     add 71444e7d Add the revision to the SVN URL on import
     add e392d30c Prevent file rewriting due to configuration when publishing to SVN
     add 98965e90 Use a password from stdin when making SVN calls
     add 4278f6c4 Compare contents rather than just paths when publishing to SVN
     add 3f91243e Add more audit logging and improve logging cleanup
     add ce86208f Extract release audit events upon publication for easier consultation
     add ccc88afe Show artifact data from recorded metadata, not the finished directory
     add 3894a78d Do not allow secret configuration from the root of the state directory
     add 75994858 Point downloads to the release destination
     new 5a24945d #1496 - Move download_path_suffix to start release, vote

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 atr/api/__init__.py                                |   3 -
 atr/attestable.py                                  |  15 ++
 atr/auditlog.py                                    |  92 ++++++++
 atr/config.py                                      |  25 +--
 atr/construct.py                                   |  21 +-
 atr/get/announce.py                                |  52 -----
 atr/get/download.py                                |  76 ++++++-
 atr/get/file.py                                    | 136 +++++++-----
 atr/get/finish.py                                  |  10 +-
 atr/get/start.py                                   |   2 +-
 atr/get/voting.py                                  |  11 +-
 atr/models/sql.py                                  |   5 +
 atr/paths.py                                       |   5 +
 atr/post/announce.py                               |  31 ---
 atr/post/start.py                                  |  10 +-
 atr/server.py                                      |   1 -
 atr/shared/__init__.py                             |   2 +
 atr/shared/announce.py                             |   1 -
 atr/shared/published.py                            |  76 +++++++
 atr/shared/start.py                                |  14 ++
 atr/static/js/src/announce-confirm.js              | 237 ---------------------
 atr/static/sh/download-urls.sh                     |   3 +-
 atr/storage/writers/announce.py                    |  52 +++--
 atr/storage/writers/release.py                     |  30 ++-
 atr/storage/writers/revision.py                    |  10 +
 atr/storage/writers/vote.py                        |   3 +
 atr/svn/__init__.py                                |  77 +++++--
 atr/tasks/checks/paths.py                          |   8 +-
 atr/tasks/svn.py                                   |   2 +-
 atr/templates/download-all.html                    |  75 ++++---
 atr/util.py                                        |  63 +++++-
 atr/worker.py                                      |  18 +-
 ....31_f6b39be8.py => 0118_2026.08.10_01f16916.py} |  16 +-
 tests/unit/test_announce_deletion_order.py         |   3 +-
 tests/unit/test_api_release_announce.py            |   1 -
 tests/unit/test_attestable.py                      |  28 +++
 tests/unit/test_auditlog.py                        | 143 +++++++++++++
 tests/unit/test_config_secrets.py                  |  35 ++-
 tests/unit/test_construct_announce.py              |  23 +-
 tests/unit/test_create_revision.py                 |  52 +++++
 tests/unit/test_download_path_suffix.py            |  64 ++++++
 tests/unit/test_get_file.py                        |  58 -----
 tests/unit/test_shared_published.py                | 128 +++++++++++
 tests/unit/test_svn_errors.py                      |  30 +++
 tests/unit/test_svn_export.py                      |  88 ++++++++
 tests/unit/test_svn_publish_announce.py            |  93 +++++++-
 tests/unit/test_svn_publish_release.py             |  52 +++++
 tests/unit/test_tree_differences.py                |  73 +++++++
 48 files changed, 1469 insertions(+), 584 deletions(-)
 create mode 100644 atr/auditlog.py
 create mode 100644 atr/shared/published.py
 copy migrations/versions/{0116_2026.07.31_f6b39be8.py => 0118_2026.08.10_01f16916.py} (73%)
 create mode 100644 tests/unit/test_auditlog.py
 delete mode 100644 tests/unit/test_get_file.py
 create mode 100644 tests/unit/test_shared_published.py
 create mode 100644 tests/unit/test_svn_export.py
 create mode 100644 tests/unit/test_svn_publish_release.py
 create mode 100644 tests/unit/test_tree_differences.py


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


(tooling-trusted-releases) 01/01: #1496 - Move download_path_suffix to start release, vote

Posted by ar...@apache.org.
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 5a24945d1ab4b06b8691da18cf36a22ea9e339fa
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Aug 10 18:24:10 2026 +0100

    #1496 - Move download_path_suffix to start release, vote
---
 atr/api/__init__.py                             |   3 -
 atr/construct.py                                |  21 ++-
 atr/get/announce.py                             |  52 ------
 atr/get/finish.py                               |  10 +-
 atr/get/start.py                                |   2 +-
 atr/get/voting.py                               |  11 +-
 atr/models/sql.py                               |   5 +
 atr/post/announce.py                            |  31 ----
 atr/post/start.py                               |  10 +-
 atr/shared/announce.py                          |   1 -
 atr/shared/start.py                             |  14 ++
 atr/static/js/src/announce-confirm.js           | 237 ------------------------
 atr/storage/writers/announce.py                 |   1 -
 atr/storage/writers/release.py                  |  30 ++-
 atr/storage/writers/vote.py                     |   3 +
 atr/tasks/checks/paths.py                       |   8 +-
 migrations/versions/0118_2026.08.10_01f16916.py |  44 +++++
 tests/unit/test_announce_deletion_order.py      |   3 +-
 tests/unit/test_api_release_announce.py         |   1 -
 tests/unit/test_construct_announce.py           |  23 ++-
 tests/unit/test_download_path_suffix.py         |  64 +++++++
 21 files changed, 211 insertions(+), 363 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 10f1a423..8de3d34f 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1152,7 +1152,6 @@ async def publisher_release_announce(
                 preview_revision_number=data.revision,
                 email_to=data.email_to,
                 body=data.body,
-                download_path_suffix=data.path_suffix,
                 fullname=asf_uid,
             )
     except storage.AccessError as e:
@@ -1276,7 +1275,6 @@ async def release_announce(
                 preview_revision_number=data.revision,
                 email_to=data.email_to,
                 body=body,
-                download_path_suffix=data.path_suffix,
                 fullname=fullname,
             )
     except storage.AccessError as e:
@@ -2327,7 +2325,6 @@ async def _release_announce_body(data: models.api.ReleaseAnnounceArgs, asf_uid:
         project_key=data.project,
         version_key=data.version,
         revision_number=revision_number,
-        download_path_suffix=data.path_suffix,
     )
     _, body = await construct.announce_release_subject_and_body(
         "", release.project.policy_announce_release_template, options
diff --git a/atr/construct.py b/atr/construct.py
index 1c51fe65..ee0c74f5 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -181,7 +181,6 @@ class AnnounceReleaseOptions:
     project_key: safe.ProjectKey
     version_key: safe.VersionKey
     revision_number: safe.RevisionNumber
-    download_path_suffix: safe.RelPath | None = None
 
 
 @dataclasses.dataclass
@@ -212,6 +211,7 @@ async def announce_release_subject_and_body(
             version=str(options.version_key),
             _project=True,
             _committee=True,
+            _project_release_policy=True,
             phase=sql.ReleasePhase.RELEASE_PREVIEW,
         ).demand(RuntimeError(f"Release {options.project_key} {options.version_key} not found"))
         if not release.committee:
@@ -224,7 +224,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_relpath = paths.committee_dist_relpath(committee, options.download_path_suffix)
+    download_relpath = paths.committee_dist_relpath(committee, effective_download_path_suffix(release))
     download_url = f"{paths.downloads_url(download_relpath)}/"
 
     values: _AnnounceValues = {
@@ -448,6 +448,23 @@ async def start_vote_subject_default(project_key: safe.ProjectKey) -> str:
     return project.policy_start_vote_subject
 
 
+def effective_download_path_suffix(release: sql.Release) -> safe.RelPath | None:
+    """The suffix a release publishes to: an explicit per-release override if set,
+    otherwise the project policy default. Both are templates, resolved for this release."""
+    override = release.download_path_suffix
+    if override == "":
+        # An empty override is a deliberate choice of the distribution root
+        return None
+    template = override if (override is not None) else release.project.policy_download_path_suffix
+    return resolve_download_path_suffix(
+        template=template,
+        project_key=release.project.key,
+        version=release.version,
+        # A top level project shares its key with its committee
+        is_top_level=(release.project.key == release.project.committee_key),
+    )
+
+
 def resolve_download_path_suffix(
     *, template: str, project_key: str, version: str, is_top_level: bool
 ) -> safe.RelPath | None:
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 40327bef..771d2a62 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -30,7 +30,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
@@ -66,21 +65,8 @@ async def selected(
     default_body_template = await construct.announce_release_default(project_key)
     subject_template_hash = construct.template_hash(default_subject_template)
 
-    # The download path suffix can be changed
-    # The defaults depend on whether the project is top level or not
     if (committee := release.project.committee) is None:
         raise ValueError("Release has no committee")
-    top_level_project = release.project.key == util.unwrap(committee.key)
-    # The stored policy template wins; empty falls back to defaults
-    default_download_path_suffix = construct.resolve_download_path_suffix(
-        template=release.project.policy_download_path_suffix,
-        project_key=release.project.key,
-        version=release.version,
-        is_top_level=top_level_project,
-    )
-    default_download_path_value = (
-        "/" if (default_download_path_suffix is None) else f"/{default_download_path_suffix!s}/"
-    )
 
     # Expand the templates
     options = construct.AnnounceReleaseOptions(
@@ -89,20 +75,11 @@ async def selected(
         project_key=project_key,
         version_key=version_key,
         revision_number=release.safe_latest_revision_number,
-        download_path_suffix=default_download_path_suffix,
     )
     default_subject, default_body = await construct.announce_release_subject_and_body(
         default_subject_template, default_body_template, options
     )
 
-    description_download_prefix = paths.downloads_url(paths.committee_dist_relpath(committee))
-    preview_url = util.as_url(
-        post.announce.preview,
-        project_key=release.project.key,
-        version_key=release.version,
-        revision_number=release.unwrap_revision_number,
-    )
-
     permitted_recipients = util.permitted_announce_recipients(
         session.uid, committee_key=util.unwrap(committee.key), project=release.project
     )
@@ -117,9 +94,6 @@ async def selected(
         default_subject=default_subject,
         subject_template_hash=subject_template_hash,
         default_body=default_body,
-        default_download_path_value=default_download_path_value,
-        download_path_description=f"The URL will be {description_download_prefix} plus this suffix",
-        preview_url=preview_url,
         embargo_message=embargo_message,
     )
 
@@ -216,13 +190,9 @@ async def _render_announce_form(
     subject_template_hash: str,
     default_subject: str,
     default_body: str,
-    default_download_path_value: str,
-    download_path_description: str,
-    preview_url: str,
 ) -> None:
     custom_subject_widget = _render_subject_field(default_subject, release.project.key)
     custom_body_widget = _render_body_field(default_body, release.project.key)
-    download_path_widget = _render_download_path_field(default_download_path_value, download_path_description)
     policy_to, policy_cc, policy_bcc = release.project.policy_recipients(sql.RecipientAction.ANNOUNCE)
     permitted_set = set(permitted_recipients)
     fallback_to = permitted_recipients[0] if permitted_recipients else None
@@ -277,14 +247,12 @@ async def _render_announce_form(
             "email_to": recipient_radios,
             "subject": custom_subject_widget,
             "body": custom_body_widget,
-            "download_path_suffix": download_path_widget,
         },
         skip=skip,
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
         border=True,
         wider_widgets=True,
     )
-    page.append(htpy.div("#announce-body-config.d-none", data_preview_url=preview_url))
 
 
 def _render_body_field(default_body: str, project_key: str) -> htm.Element:
@@ -305,29 +273,12 @@ def _render_body_field(default_body: str, project_key: str) -> htm.Element:
     return htm.div[textarea, link]
 
 
-def _render_download_path_field(default_value: str, description: str) -> htm.Element:
-    """Render the download path suffix field with custom help text."""
-    base_text = description.split(" plus this suffix")[0] if (" plus this suffix" in description) else description
-    return htm.div[
-        htpy.input(
-            "#download_path_suffix.form-control",
-            type="text",
-            name="download_path_suffix",
-            value=default_value,
-        ),
-        htpy.div(".form-text.text-muted.mt-2", data_base_text=base_text)[description],
-    ]
-
-
 async def _render_page(
     release: sql.Release,
     permitted_recipients: list[str],
     default_subject: str,
     subject_template_hash: str,
     default_body: str,
-    default_download_path_value: str,
-    download_path_description: str,
-    preview_url: str,
     embargo_message: str | None,
 ) -> htm.Element:
     """Render the announce page."""
@@ -376,9 +327,6 @@ async def _render_page(
         subject_template_hash=subject_template_hash,
         default_subject=default_subject,
         default_body=default_body,
-        default_download_path_value=default_download_path_value,
-        download_path_description=download_path_description,
-        preview_url=preview_url,
     )
     return page.collect()
 
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 8b3785da..c37ff546 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -451,15 +451,7 @@ def _render_task(task: sql.Task) -> htm.Element:
 
 
 def _svn_download_path_default(release: sql.Release) -> str:
-    committee = release.project.committee
-    if committee is None:
-        return ""
-    suffix = construct.resolve_download_path_suffix(
-        template=release.project.policy_download_path_suffix,
-        project_key=release.project.key,
-        version=release.version,
-        is_top_level=(release.project.key == util.unwrap(committee.key)),
-    )
+    suffix = construct.effective_download_path_suffix(release)
     return str(suffix) if suffix is not None else ""
 
 
diff --git a/atr/get/start.py b/atr/get/start.py
index d24eb194..da2dc3fa 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -198,7 +198,7 @@ async def _render_page(project: sql.Project, releases: list[sql.Release], can_cr
         submit_classes="btn-primary btn-lg",
         submit_label="Start new release",
         cancel_url=util.as_url(root.index),
-        defaults={"project_key": project.key},
+        defaults={"project_key": project.key, "download_path_suffix": project.policy_download_path_suffix},
         pre_submit=_cycle_preview(project),
         skip=skip,
     )
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 1295b506..8836af02 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -115,7 +115,7 @@ async def selected(
         submitted_concerns = util.submitted_concerns_from_flash(flash_data)
 
         publish_eligible = user.is_committee_member(committee, session.uid)
-        default_download_path_suffix = _default_download_path_suffix(release, committee)
+        default_download_path_suffix = _default_download_path_suffix(release)
         content = await _render_page(
             release=release,
             revision_number=str(release.safe_latest_revision_number),
@@ -171,13 +171,8 @@ def _add_automatic_publish_fields(
     ]
 
 
-def _default_download_path_suffix(release: sql.Release, committee: sql.Committee) -> safe.RelPath | None:
-    return construct.resolve_download_path_suffix(
-        template=release.project.policy_download_path_suffix,
-        project_key=release.project.key,
-        version=release.version,
-        is_top_level=(release.project.key == util.unwrap(committee.key)),
-    )
+def _default_download_path_suffix(release: sql.Release) -> safe.RelPath | None:
+    return construct.effective_download_path_suffix(release)
 
 
 def _podling_vote_round(release: sql.Release, committee: sql.Committee) -> int | None:
diff --git a/atr/models/sql.py b/atr/models/sql.py
index c55f30d9..4855157b 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1464,6 +1464,11 @@ class Release(sqlmodel.SQLModel, table=True):
 
     check_cache_key: str | None = sqlmodel.Field(default=None, **example("ef0ccb0a-3514-4b65-abcd-879850349f74"))
 
+    # Per-release override for the SVN publication path suffix, chosen before the files
+    # are published. Null means we've not set one, so fall back to the project policy
+    # default; an empty string is a deliberate choice of the distribution root.
+    download_path_suffix: str | None = sqlmodel.Field(default=None, **example("incubator/example/0.0.1"))
+
     # M-1: Release -> Project
     # 1-M: Project -> [Release]
     project_key: str = sqlmodel.Field(foreign_key="project.key", **example("example"))
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 96bd9fd3..2c161f6a 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -27,7 +27,6 @@ import quart
 import atr.blueprints.post as post
 import atr.config as config
 import atr.construct as construct
-import atr.form as form
 import atr.get as get
 import atr.models.safe as safe
 import atr.models.sql as sql
@@ -46,35 +45,6 @@ _DOWNLOAD_PAGE_TIMEOUT: Final = aiohttp.ClientTimeout(total=10, connect=5)
 _REDIRECT_STATUSES: Final = frozenset({301, 302, 303, 307, 308})
 
 
-class PreviewForm(form.Form):
-    download_path_suffix: safe.OptionalRelPath = form.label("Download path suffix")
-
-
-@post.typed
-async def preview(
-    session: web.Committer,
-    _announce_preview: Literal["announce/preview"],
-    project_key: safe.ProjectKey,
-    version_key: safe.VersionKey,
-    revision_number: safe.RevisionNumber,
-    preview_form: PreviewForm,
-) -> web.QuartResponse:
-    """
-    URL: /announce/preview/<project_key>/<version_key>/<revision_number>
-    """
-    default_body_template = await construct.announce_release_default(project_key)
-    options = construct.AnnounceReleaseOptions(
-        asfuid=session.uid,
-        fullname=session.fullname,
-        project_key=project_key,
-        version_key=version_key,
-        revision_number=revision_number,
-        download_path_suffix=preview_form.download_path_suffix,
-    )
-    _, body = await construct.announce_release_subject_and_body("", default_body_template, options)
-    return web.TextResponse(body)
-
-
 @post.typed
 async def selected(
     session: web.Committer,
@@ -128,7 +98,6 @@ async def selected(
                 preview_revision_number=preview_revision_number,
                 email_to=announce_form.email_to,
                 body=announce_form.body,
-                download_path_suffix=announce_form.download_path_suffix,
                 fullname=session.fullname,
                 subject_template_hash=announce_form.subject_template_hash,
                 email_cc=announce_form.email_cc,
diff --git a/atr/post/start.py b/atr/post/start.py
index f26db495..c1d084b1 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -80,12 +80,18 @@ async def selected(
             if start_release_form.expedited:
                 wacm = await write.as_project_committee_member(project_key)
                 new_release, _project = await wacm.release.start_expedited(
-                    project_key, version, start_release_form.auto_archive_prior
+                    project_key,
+                    version,
+                    start_release_form.auto_archive_prior,
+                    download_path_suffix=start_release_form.download_path_suffix,
                 )
             else:
                 wacp = await write.as_project_committee_participant(project_key)
                 new_release, _project = await wacp.release.start(
-                    project_key, version, start_release_form.auto_archive_prior
+                    project_key,
+                    version,
+                    start_release_form.auto_archive_prior,
+                    download_path_suffix=start_release_form.download_path_suffix,
                 )
 
         return await session.redirect(
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index fb14c6fb..5157515b 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -34,7 +34,6 @@ class AnnounceForm(form.Form):
     subject: str = form.label("Subject", widget=form.Widget.CUSTOM, required=True)
     subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
     body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000, required=True)
-    download_path_suffix: safe.OptionalRelPath = form.label("Download path suffix", widget=form.Widget.CUSTOM)
     download_page: form.OptionalURL = form.label(
         "Download page URL",
         "URL of the project's official download page. It is checked for a 200 response and then"
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 0e2cc76c..ff3f0a36 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -21,6 +21,7 @@ import pydantic
 
 import atr.form as form
 import atr.models.sql as sql
+import atr.models.validation as validation
 import atr.util as util
 
 _REQUIRED_METADATA_FIELDS: Final[tuple[tuple[str, str], ...]] = (
@@ -52,6 +53,13 @@ class StartReleaseForm(form.Form):
         " with a warning email sent at 80 days.",
         required=True,
     )
+    download_path_suffix: str = form.label(
+        "Download path suffix",
+        "Pre-fills the SVN publish path for this release. Defaults to the project policy;"
+        " edit it to override the path for this release, or clear it to publish to the"
+        " distribution root. May use {{MAJOR_VERSION}}, {{PROJECT_KEY}}, and {{VERSION}}.",
+        default="",
+    )
     auto_archive_prior: form.Bool = form.label(
         "Auto archive prior release",
         "If set, release of this version will auto-archive the prior release in the cycle",
@@ -69,3 +77,9 @@ class StartReleaseForm(form.Form):
         if error := util.version_key_error(value):
             raise ValueError(error)
         return value
+
+    @pydantic.field_validator("download_path_suffix", mode="before")
+    @classmethod
+    def validate_download_path_suffix(cls, value: str) -> str:
+        validation.validate_download_path_suffix(value)
+        return value
diff --git a/atr/static/js/src/announce-confirm.js b/atr/static/js/src/announce-confirm.js
index 57274df0..430eef01 100644
--- a/atr/static/js/src/announce-confirm.js
+++ b/atr/static/js/src/announce-confirm.js
@@ -40,243 +40,6 @@ function initAnnounceConfirm() {
 	updateButtonState();
 }
 
-function createBodyWarningDiv() {
-	const warningDiv = document.createElement("div");
-	warningDiv.className = "alert alert-warning mt-2 d-none";
-	warningDiv.innerHTML =
-		"<strong>Note:</strong> The message body has been customised. The download link in this message will no longer update automatically. " +
-		'<br><button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="discard-announce-body-changes">Discard changes</button>';
-	return warningDiv;
-}
-
-function createModifiedStateUpdater(bodyTextarea, warningDiv, state) {
-	return function updateModifiedState() {
-		const currentlyModified = bodyTextarea.value !== state.pristineBody;
-
-		if (currentlyModified !== state.isModified) {
-			state.isModified = currentlyModified;
-
-			if (state.isModified) {
-				warningDiv.classList.remove("d-none");
-			} else {
-				warningDiv.classList.add("d-none");
-			}
-		}
-	};
-}
-
-function normaliseDownloadPathSuffix(rawSuffix) {
-	let suffix = rawSuffix;
-
-	if (suffix.includes("..") || suffix.includes("//")) {
-		return {
-			error: "Download path suffix must not contain .. or //",
-			suffix: null,
-		};
-	}
-	if (suffix.startsWith("./")) {
-		suffix = suffix.slice(1);
-	} else if (suffix === ".") {
-		suffix = "/";
-	}
-	if (!suffix.startsWith("/")) {
-		suffix = `/${suffix}`;
-	}
-	if (!suffix.endsWith("/")) {
-		suffix = `${suffix}/`;
-	}
-	if (suffix.includes("/.")) {
-		return {
-			error: "Download path suffix must not contain /.",
-			suffix: null,
-		};
-	}
-
-	return { error: null, suffix };
-}
-
-function normaliseTextareaValue(value) {
-	const textarea = document.createElement("textarea");
-	textarea.value = value;
-	return textarea.value;
-}
-
-function createBodyFetcher(
-	previewUrl,
-	csrfToken,
-	bodyTextarea,
-	state,
-	updateModifiedState,
-) {
-	return async function fetchNewBody(pathSuffix) {
-		const requestId = state.requestId + 1;
-		state.requestId = requestId;
-
-		try {
-			const formData = new FormData();
-			formData.append("download_path_suffix", pathSuffix);
-			if (csrfToken) {
-				formData.append("csrf_token", csrfToken);
-			}
-
-			const response = await fetch(previewUrl, {
-				method: "POST",
-				body: formData,
-			});
-
-			if (!response.ok) {
-				console.error("Failed to fetch new body:", response.statusText);
-				return;
-			}
-
-			const newBody = normaliseTextareaValue(await response.text());
-			if (requestId !== state.requestId) {
-				return;
-			}
-
-			state.pristineBody = newBody;
-			if (!state.isModified) {
-				bodyTextarea.value = newBody;
-			}
-			updateModifiedState();
-		} catch (error) {
-			console.error("Error fetching new body:", error);
-		}
-	};
-}
-
-function getAnnounceBodySyncElements() {
-	const config = document.getElementById("announce-body-config");
-	const bodyTextarea = document.getElementById("body");
-	const pathInput = document.getElementById("download_path_suffix");
-	const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
-
-	if (!config || !bodyTextarea || !pathInput) {
-		return null;
-	}
-
-	const previewUrl = config.dataset.previewUrl;
-	if (!previewUrl) {
-		return null;
-	}
-
-	return {
-		bodyTextarea,
-		pathInput,
-		csrfToken,
-		previewUrl,
-	};
-}
-
-function createAnnounceBodyState(bodyTextarea) {
-	return {
-		pristineBody: bodyTextarea.value,
-		isModified: false,
-		requestId: 0,
-	};
-}
-
-function createPathSyncHandler(pathInput, fetchNewBody) {
-	return function syncBodyWithPath() {
-		const normalised = normaliseDownloadPathSuffix(pathInput.value);
-		if (normalised.error || !normalised.suffix) {
-			return;
-		}
-		fetchNewBody(normalised.suffix);
-	};
-}
-
-function attachAnnounceBodySyncListeners(
-	bodyTextarea,
-	pathInput,
-	discardButton,
-	state,
-	updateModifiedState,
-	fetchNewBody,
-) {
-	const syncBodyWithPath = createPathSyncHandler(pathInput, fetchNewBody);
-	let bodyDebounce;
-
-	bodyTextarea.addEventListener("input", updateModifiedState);
-	pathInput.addEventListener("input", () => {
-		clearTimeout(bodyDebounce);
-		bodyDebounce = setTimeout(syncBodyWithPath, 10);
-	});
-
-	discardButton.addEventListener("click", () => {
-		bodyTextarea.value = state.pristineBody;
-		updateModifiedState();
-	});
-}
-
-function initAnnounceBodySync() {
-	const elements = getAnnounceBodySyncElements();
-	if (!elements) {
-		return;
-	}
-
-	const { bodyTextarea, pathInput, csrfToken, previewUrl } = elements;
-	const state = createAnnounceBodyState(bodyTextarea);
-
-	const warningDiv = createBodyWarningDiv();
-	bodyTextarea.parentNode.append(warningDiv);
-
-	const discardButton = document.getElementById(
-		"discard-announce-body-changes",
-	);
-	const updateModifiedState = createModifiedStateUpdater(
-		bodyTextarea,
-		warningDiv,
-		state,
-	);
-	const fetchNewBody = createBodyFetcher(
-		previewUrl,
-		csrfToken,
-		bodyTextarea,
-		state,
-		updateModifiedState,
-	);
-	attachAnnounceBodySyncListeners(
-		bodyTextarea,
-		pathInput,
-		discardButton,
-		state,
-		updateModifiedState,
-		fetchNewBody,
-	);
-}
-
-function initDownloadPathValidation() {
-	const pathInput = document.getElementById("download_path_suffix");
-	const pathHelpText = pathInput
-		? pathInput.parentElement.querySelector(".form-text")
-		: null;
-
-	if (!pathInput || !pathHelpText) {
-		return;
-	}
-
-	const baseText = pathHelpText.dataset.baseText || "";
-	let pathDebounce;
-
-	const updatePathHelpText = () => {
-		const normalised = normaliseDownloadPathSuffix(pathInput.value);
-		if (normalised.error || !normalised.suffix) {
-			pathHelpText.textContent = normalised.error;
-			return;
-		}
-		pathHelpText.textContent = baseText + normalised.suffix;
-	};
-
-	pathInput.addEventListener("input", () => {
-		clearTimeout(pathDebounce);
-		pathDebounce = setTimeout(updatePathHelpText, 10);
-	});
-	updatePathHelpText();
-}
-
 document.addEventListener("DOMContentLoaded", () => {
 	initAnnounceConfirm();
-	initAnnounceBodySync();
-	initDownloadPathValidation();
 });
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 8f0ecdb8..adc24b93 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -121,7 +121,6 @@ class ReleaseManager(CommitteeParticipant):
         preview_revision_number: safe.RevisionNumber | None,
         email_to: str,
         body: str,
-        download_path_suffix: safe.RelPath | None,
         fullname: str,
         subject_template_hash: str | None = None,
         email_cc: list[str] | None = None,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 16068bd7..daf1fe57 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -285,10 +285,13 @@ async def _start_release(
     version: safe.VersionKey,
     auto_archive: bool,
     expedited: bool,
+    download_path_suffix: str | None = None,
 ) -> tuple[sql.Release, sql.Project]:
     """Creates the initial release draft record and revision directory."""
     await data.begin_immediate()
-    project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).get()
+    project = await data.project(
+        key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True, _release_policy=True
+    ).get()
     if not project:
         raise storage.AccessError(f"Project {project_key} not found", status=404)
 
@@ -304,6 +307,12 @@ async def _start_release(
     cycle_key = await _ensure_project_cycle(data, project, version)
 
     now = datetime.datetime.now(datetime.UTC)
+    # Null defers to the project policy default; keep it null when the release manager left
+    # the pre-filled policy value untouched, so we don't pin a stale copy of the template
+    if (download_path_suffix is None) or (download_path_suffix == project.policy_download_path_suffix):
+        download_path_override = None
+    else:
+        download_path_override = download_path_suffix
     release = sql.Release(
         phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
         project_key=project.key,
@@ -314,6 +323,7 @@ async def _start_release(
         expedited=expedited,
         created=now,
         activity_at=now,
+        download_path_suffix=download_path_override,
     )
     write.ensure_release_writable(release)
     data.add(release)
@@ -876,7 +886,11 @@ class CommitteeParticipant(FoundationCommitter):
         return int(result.scalar_one())
 
     async def start(
-        self, project_key: safe.ProjectKey, version: safe.VersionKey, auto_archive: bool = False
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        auto_archive: bool = False,
+        download_path_suffix: str | None = None,
     ) -> tuple[sql.Release, sql.Project]:
         return await _start_release(
             self.__data,
@@ -887,6 +901,7 @@ class CommitteeParticipant(FoundationCommitter):
             version,
             auto_archive,
             expedited=False,
+            download_path_suffix=download_path_suffix,
         )
 
     async def upload_file(self, upload_args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
@@ -1202,6 +1217,10 @@ class ReleaseManager(CommitteeParticipant):
                 version_key=str(version_key),
                 revision_number=str(expected_revision),
             )
+            # Lock the chosen suffix onto the release so the catalogue and the
+            # announcement email read back exactly what we published to
+            release.download_path_suffix = str(download_path_suffix) if download_path_suffix is not None else ""
+            self.__data.add(release)
             self.__data.add(task)
             await self.__data.commit()
         except storage.AccessError:
@@ -1409,7 +1428,11 @@ class CommitteeMember(ReleaseManager):
             raise storage.AccessError("A CAP approval request for this release is already in progress.", status=409)
 
     async def start_expedited(
-        self, project_key: safe.ProjectKey, version: safe.VersionKey, auto_archive: bool = False
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        auto_archive: bool = False,
+        download_path_suffix: str | None = None,
     ) -> tuple[sql.Release, sql.Project]:
         return await _start_release(
             self.__data,
@@ -1420,6 +1443,7 @@ class CommitteeMember(ReleaseManager):
             version,
             auto_archive,
             expedited=True,
+            download_path_suffix=download_path_suffix,
         )
 
 
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index fa3d487f..a7fcf890 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -408,6 +408,9 @@ class ReleaseManager(CommitteeParticipant):
                     raise storage.AccessError("Automatic SVN publish requires a committee member initiator", status=403)
                 if automatic_publish_asf_uid is None:
                     automatic_publish_asf_uid = self.__asf_uid
+                # Lock the chosen suffix onto the release now as the single source of truth for publish and email
+                release.download_path_suffix = str(download_path_suffix) if download_path_suffix is not None else ""
+                self.__data.add(release)
             if permitted_recipients is None:
                 permitted_recipients = util.permitted_podling_first_round_recipients(
                     self.__asf_uid,
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
index fcbb7fe9..95999ff7 100644
--- a/atr/tasks/checks/paths.py
+++ b/atr/tasks/checks/paths.py
@@ -197,13 +197,7 @@ async def _check_download_suffix_duplication(
             _project_release_policy=True,
         ).demand(RuntimeError(f"Release {args.project_key} {args.version_key} not found"))
 
-    committee = release.committee
-    suffix = construct.resolve_download_path_suffix(
-        template=release.project.policy_download_path_suffix,
-        project_key=release.project.key,
-        version=str(release.version),
-        is_top_level=(committee is not None) and (release.project.key == committee.key),
-    )
+    suffix = construct.effective_download_path_suffix(release)
     if suffix is None:
         return
 
diff --git a/migrations/versions/0118_2026.08.10_01f16916.py b/migrations/versions/0118_2026.08.10_01f16916.py
new file mode 100644
index 00000000..ac74b73f
--- /dev/null
+++ b/migrations/versions/0118_2026.08.10_01f16916.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Add a per-release override for the publication path suffix
+
+Revision ID: 0118_2026.08.10_01f16916
+Revises: 0117_2026.08.03_7768fea7
+Create Date: 2026-08-10 15:24:16.337305+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0118_2026.08.10_01f16916"
+down_revision: str | None = "0117_2026.08.03_7768fea7"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("release", 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("release", schema=None) as batch_op:
+        batch_op.drop_column("download_path_suffix")
diff --git a/tests/unit/test_announce_deletion_order.py b/tests/unit/test_announce_deletion_order.py
index 88bab8ef..40b12e35 100644
--- a/tests/unit/test_announce_deletion_order.py
+++ b/tests/unit/test_announce_deletion_order.py
@@ -85,7 +85,6 @@ def release_arguments() -> dict[str, Any]:
         "preview_revision_number": safe.RevisionNumber("00003"),
         "email_to": "announce@example.apache.org",
         "body": "Body",
-        "download_path_suffix": None,
         "fullname": "Alice",
     }
 
@@ -96,6 +95,7 @@ def release_row() -> SimpleNamespace:
         committee=SimpleNamespace(key="alpha"),
         is_active=True,
         key="example",
+        policy_download_path_suffix="",
         release_policy=None,
     )
     return SimpleNamespace(
@@ -104,6 +104,7 @@ def release_row() -> SimpleNamespace:
         project=project,
         project_key="example",
         release_policy=None,
+        download_path_suffix=None,
         safe_latest_revision_number=safe.RevisionNumber("00003"),
         safe_version_key=safe.VersionKey("2.0.0"),
         unwrap_revision_number="00003",
diff --git a/tests/unit/test_api_release_announce.py b/tests/unit/test_api_release_announce.py
index 1afe7d78..37488ce4 100644
--- a/tests/unit/test_api_release_announce.py
+++ b/tests/unit/test_api_release_announce.py
@@ -67,7 +67,6 @@ async def test_release_announce_omitted_body_renders_policy_template(monkeypatch
     assert calls["subject"] == ""
     assert calls["body"] == "Body {{VERSION}}"
     assert calls["options"].revision_number == safe.RevisionNumber("00003")
-    assert calls["options"].download_path_suffix == safe.RelPath("example/1.0.0")
     assert calls["options"].fullname == "Example User"
 
 
diff --git a/tests/unit/test_construct_announce.py b/tests/unit/test_construct_announce.py
index 76e02cd8..3fd1514f 100644
--- a/tests/unit/test_construct_announce.py
+++ b/tests/unit/test_construct_announce.py
@@ -56,6 +56,8 @@ async def test_announce_release_subject_and_body_does_not_expand_injected_tag(mo
         short_display_name="Apache MyProject",
         name="MyProject",
         key="myproject",
+        committee_key="myproject",
+        policy_download_path_suffix="",
         bug_database="bug_database",
         download_page="download_page",
         homepage="homepage",
@@ -63,7 +65,9 @@ async def test_announce_release_subject_and_body_does_not_expand_injected_tag(mo
         mailing_lists="mailing_lists",
         repositories="repository",
     )
-    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+    release = SimpleNamespace(
+        key="myproject-1.0.0", committee=committee, project=project, download_path_suffix=None, version="1.0.0"
+    )
     revision = SimpleNamespace(number="1", tag="{{YOUR_FULL_NAME}}")
 
     monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
@@ -92,6 +96,8 @@ async def test_announce_release_subject_and_body_uses_podling_canonical_download
         short_display_name="Apache MyProject",
         name="MyProject",
         key="myproject",
+        committee_key="myproject",
+        policy_download_path_suffix="",
         bug_database="bug_database",
         download_page="download_page",
         homepage="homepage",
@@ -99,7 +105,13 @@ async def test_announce_release_subject_and_body_uses_podling_canonical_download
         mailing_lists="mailing_lists",
         repositories="repository",
     )
-    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+    release = SimpleNamespace(
+        key="myproject-1.0.0",
+        committee=committee,
+        project=project,
+        download_path_suffix="apache-myproject-1.0.0",
+        version="1.0.0",
+    )
     revision = SimpleNamespace(number="1", tag=None)
 
     monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
@@ -113,7 +125,6 @@ async def test_announce_release_subject_and_body_uses_podling_canonical_download
             project_key=safe.ProjectKey("myproject"),
             version_key=safe.VersionKey("1.0.0"),
             revision_number=safe.RevisionNumber("1"),
-            download_path_suffix=safe.RelPath("apache-myproject-1.0.0"),
         ),
     )
 
@@ -128,6 +139,8 @@ async def test_announce_release_subject_and_body_uses_top_level_canonical_downlo
         short_display_name="Apache MyProject",
         name="MyProject",
         key="myproject",
+        committee_key="myproject",
+        policy_download_path_suffix="",
         bug_database="bug_database",
         download_page="download_page",
         homepage="homepage",
@@ -135,7 +148,9 @@ async def test_announce_release_subject_and_body_uses_top_level_canonical_downlo
         mailing_lists="mailing_lists",
         repositories="repository",
     )
-    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+    release = SimpleNamespace(
+        key="myproject-1.0.0", committee=committee, project=project, download_path_suffix=None, version="1.0.0"
+    )
     revision = SimpleNamespace(number="1", tag=None)
 
     monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
diff --git a/tests/unit/test_download_path_suffix.py b/tests/unit/test_download_path_suffix.py
index bd945194..579da407 100644
--- a/tests/unit/test_download_path_suffix.py
+++ b/tests/unit/test_download_path_suffix.py
@@ -15,13 +15,29 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
+
 import pytest
 
 import atr.construct as construct
 import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.models.validation as validation
 
 
+def _release(*, override: str | None, policy: str, project_key: str, committee_key: str, version: str) -> sql.Release:
+    release_policy = sql.ReleasePolicy(download_path_suffix=policy)
+    project = sql.Project(key=project_key, committee_key=committee_key, release_policy=release_policy)
+    return sql.Release(
+        project_key=project_key,
+        project=project,
+        version=version,
+        download_path_suffix=override,
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+    )
+
+
 def test_empty_template_subproject_uses_key_and_version() -> None:
     result = construct.resolve_download_path_suffix(
         template="", project_key="tomcat-native", version="2.0.0", is_top_level=False
@@ -68,6 +84,54 @@ def test_template_expands_placeholders() -> None:
     assert result == safe.RelPath("binaries/tomcat-native-2.0.0")
 
 
+def test_effective_override_wins_over_policy() -> None:
+    release = _release(
+        override="custom/path", policy="{{VERSION}}", project_key="tomcat", committee_key="tomcat", version="10.1.0"
+    )
+
+    assert construct.effective_download_path_suffix(release) == safe.RelPath("custom/path")
+
+
+def test_effective_template_override_resolves_placeholders() -> None:
+    release = _release(
+        override="rc/{{VERSION}}", policy="ignored", project_key="tomcat", committee_key="tomcat", version="10.1.0"
+    )
+
+    assert construct.effective_download_path_suffix(release) == safe.RelPath("rc/10.1.0")
+
+
+def test_effective_empty_override_publishes_to_root() -> None:
+    release = _release(
+        override="", policy="{{VERSION}}", project_key="tomcat-native", committee_key="tomcat", version="2.0.0"
+    )
+
+    assert construct.effective_download_path_suffix(release) is None
+
+
+def test_effective_without_override_falls_back_to_policy() -> None:
+    release = _release(
+        override=None,
+        policy="maven-{{MAJOR_VERSION}}/{{VERSION}}",
+        project_key="maven",
+        committee_key="maven",
+        version="5.0.0-rc-5",
+    )
+
+    assert construct.effective_download_path_suffix(release) == safe.RelPath("maven-5/5.0.0-rc-5")
+
+
+def test_effective_without_override_uses_subproject_default() -> None:
+    release = _release(override=None, policy="", project_key="tomcat-native", committee_key="tomcat", version="2.0.0")
+
+    assert construct.effective_download_path_suffix(release) == safe.RelPath("tomcat-native-2.0.0")
+
+
+def test_effective_without_override_top_level_publishes_to_root() -> None:
+    release = _release(override=None, policy="", project_key="tomcat", committee_key="tomcat", version="10.1.0")
+
+    assert construct.effective_download_path_suffix(release) is None
+
+
 def test_validate_download_path_suffix_accepts_empty() -> None:
     validation.validate_download_path_suffix("")
 


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