You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/05/26 18:47:48 UTC
(tooling-trusted-releases) branch sbp updated (e5b6299d -> 39cdc04f)
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a change to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard e5b6299d Allow optional publishing to SVN during vote resolution, in a task
new 39cdc04f Allow optional publishing to SVN during vote resolution, in a task
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (e5b6299d)
\
N -- N -- N refs/heads/sbp (39cdc04f)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
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/constants.py | 4 ++
atr/db/interaction.py | 10 ++---
atr/models/args.py | 1 -
atr/models/results.py | 1 -
atr/paths.py | 10 +++--
atr/post/keys.py | 7 ++-
atr/server.py | 9 ----
atr/storage/writers/announce.py | 43 ++++--------------
atr/storage/writers/release.py | 46 ++++---------------
atr/tasks/svn.py | 7 ++-
atr/util.py | 68 ++++++++++++++--------------
tests/unit/test_svn_publish_announce.py | 7 +--
tests/unit/test_svn_publish_helpers.py | 59 +++++++++++++-----------
tests/unit/test_svn_publish_start_vote.py | 7 ++-
tests/unit/test_svn_publish_tasks_db.py | 26 ++---------
tests/unit/test_svn_publish_writer.py | 75 ++++++++++++++++---------------
16 files changed, 153 insertions(+), 227 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: Allow optional publishing to SVN during vote resolution, in a task
Posted by sb...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 39cdc04f4626d743275e7dd897fd14541166b17c
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue May 26 17:17:02 2026 +0100
Allow optional publishing to SVN during vote resolution, in a task
---
atr/constants.py | 4 +
atr/db/interaction.py | 10 +-
atr/get/voting.py | 56 ++++++
atr/models/args.py | 13 +-
atr/models/results.py | 1 -
atr/paths.py | 10 +-
atr/post/keys.py | 7 +-
atr/post/voting.py | 43 ++++
atr/shared/voting.py | 10 +
atr/storage/writers/announce.py | 104 +++++++---
atr/storage/writers/release.py | 131 ++++++++++++
atr/storage/writers/vote.py | 103 +++++++++-
atr/tasks/__init__.py | 3 +-
atr/tasks/svn.py | 7 +-
atr/tasks/svnpub.py | 28 +++
atr/util.py | 126 ++++++++++--
tests/unit/test_svn_publish_announce.py | 64 ++++++
tests/unit/test_svn_publish_helpers.py | 102 ++++++++--
tests/unit/test_svn_publish_start_vote.py | 127 ++++++++++++
tests/unit/test_svn_publish_tasks_db.py | 26 +--
tests/unit/test_svn_publish_writer.py | 319 ++++++++++++++++++++++++++++++
21 files changed, 1179 insertions(+), 115 deletions(-)
diff --git a/atr/constants.py b/atr/constants.py
index 84f29728..d24ac8ba 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -17,6 +17,10 @@
from typing import Final
+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"
+
APACHE_LICENSE_2_0: Final[str] = """\
Apache License
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 48ec823b..0350af15 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -590,7 +590,6 @@ async def release_in_flight_svn_publish_task(
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
revision_number: safe.RevisionNumber,
- target_url: str | None = None,
caller_data: db.Session | None = None,
) -> sql.Task | None:
"""Return the most recent queued or active SVN_PUBLISH task."""
@@ -606,8 +605,6 @@ async def release_in_flight_svn_publish_task(
.order_by(via(sql.Task.added).desc())
.limit(1)
)
- if target_url is not None:
- query = query.where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.target_url") == target_url)
return (await data.execute(query)).scalar_one_or_none()
@@ -615,7 +612,6 @@ async def release_latest_failed_svn_publish_task(
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
revision_number: safe.RevisionNumber,
- target_url: str | None = None,
caller_data: db.Session | None = None,
) -> sql.Task | None:
"""Return the most recently failed SVN_PUBLISH task."""
@@ -631,8 +627,6 @@ async def release_latest_failed_svn_publish_task(
.order_by(via(sql.Task.added).desc())
.limit(1)
)
- if target_url is not None:
- query = query.where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.target_url") == target_url)
return (await data.execute(query)).scalar_one_or_none()
@@ -1036,8 +1030,10 @@ def _svn_publish_result(task: sql.Task) -> results.SvnPublish | None:
if isinstance(result, results.SvnPublish):
return result
if isinstance(result, dict):
+ candidate = dict(result)
+ candidate.pop("target_url", None)
try:
- parsed = results.ResultsAdapter.validate_python(result)
+ parsed = results.ResultsAdapter.validate_python(candidate)
except pydantic.ValidationError:
return None
if isinstance(parsed, results.SvnPublish):
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 442c6c38..ff0ba1fb 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -23,6 +23,7 @@ import htpy
import quart
import atr.blueprints.get as get
+import atr.config as config
import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
@@ -40,6 +41,7 @@ import atr.sessions as sessions
import atr.shared as shared
import atr.storage as storage
import atr.template as template
+import atr.user as user
import atr.util as util
import atr.web as web
@@ -111,6 +113,8 @@ async def selected(
flash_data = await sessions.form_error_pop(quart.request.path)
submitted_concerns = util.submitted_concerns_from_flash(flash_data)
+ publish_eligible = bool(config.get().SVN_PUBLISH_URL) and user.is_committee_member(committee, session.uid)
+ default_download_path_suffix = _default_download_path_suffix(release, committee)
content = await _render_page(
release=release,
revision_number=str(release.safe_latest_revision_number),
@@ -127,6 +131,8 @@ async def selected(
concern_groups=concern_groups,
submitted_concerns=submitted_concerns,
flash_data=flash_data,
+ publish_eligible=publish_eligible,
+ default_download_path_suffix=default_download_path_suffix,
)
return await template.blank(
@@ -136,17 +142,61 @@ async def selected(
)
+def _add_automatic_publish_fields(
+ custom: dict[str, htm.Element | htm.VoidElement],
+ skip: list[str],
+ publish_eligible: bool,
+ vote_mode: sql.VoteMode,
+ podling_vote_round: int | None,
+) -> None:
+ if not publish_eligible:
+ skip.append("automatic_publish_when_resolved")
+ skip.append("download_path_suffix")
+ return
+ vote_label = _publish_vote_label(vote_mode, podling_vote_round)
+ custom["automatic_publish_when_resolved"] = htm.div[
+ htpy.input(
+ type="checkbox",
+ name="automatic_publish_when_resolved",
+ id="automatic_publish_when_resolved",
+ value="on",
+ checked=True,
+ class_="form-check-input",
+ ),
+ htm.div(".form-text.text-muted.mt-1")[
+ "If enabled, ATR will publish the preview revision to SVN automatically",
+ f" when the final approving {vote_label} resolves.",
+ ],
+ ]
+
+
async def _check_keys_warning(committee: sql.Committee) -> bool:
keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
return not await aiofiles.os.path.isfile(keys_file_path)
+def _default_download_path_suffix(release: sql.Release, committee: sql.Committee) -> safe.RelPath | None:
+ if release.project.key == util.unwrap(committee.key):
+ return None
+ return safe.RelPath(f"{release.project.key}-{release.version}")
+
+
def _podling_vote_round(release: sql.Release, committee: sql.Committee) -> int | None:
if not committee.is_podling:
return None
return 2 if (release.podling_thread_id is not None) else 1
+def _publish_vote_label(vote_mode: sql.VoteMode, podling_vote_round: int | None) -> str:
+ if vote_mode != sql.VoteMode.TRUSTED:
+ return "vote"
+ if podling_vote_round == 1:
+ return "first round vote"
+ if podling_vote_round == 2:
+ return "second round vote"
+ return "vote"
+
+
def _render_body_field(default_body: str, project_key: str) -> htm.Element:
"""Render the body textarea with a link to edit the template."""
textarea = htpy.textarea(
@@ -181,6 +231,8 @@ async def _render_page(
concern_groups: list[util.ConcernGroup],
submitted_concerns: list[str],
flash_data: dict,
+ publish_eligible: bool,
+ default_download_path_suffix: safe.RelPath | None,
) -> htm.Element:
page = htm.Block()
@@ -314,6 +366,9 @@ async def _render_page(
skip.append("notify_when_finished")
skip.append("automatic_resolve_when_finished")
+ _add_automatic_publish_fields(custom, skip, publish_eligible, vote_mode, podling_vote_round)
+
+ download_suffix_default = str(default_download_path_suffix) if (default_download_path_suffix is not None) else ""
vote_form = await form.render(
model_cls=shared.voting.StartVotingForm,
submit_label="Send vote email",
@@ -324,6 +379,7 @@ async def _render_page(
"subject_template_hash": subject_template_hash,
"body": default_body,
"rendered_revision": revision_number,
+ "download_path_suffix": download_suffix_default,
},
custom=custom,
skip=skip,
diff --git a/atr/models/args.py b/atr/models/args.py
index 8093c884..145f7add 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -110,6 +110,18 @@ class Initiate(schema.Strict):
default=False,
description="Automatically resolve a Trusted Vote when the voting period ends",
)
+ automatic_publish_when_resolved: bool = pydantic.Field(
+ default=False,
+ description="Publish the preview revision to SVN automatically when the final approving vote resolves",
+ )
+ automatic_publish_asf_uid: str | None = pydantic.Field(
+ default=None,
+ description="ASF UID to attribute automatic SVN publication to",
+ )
+ download_path_suffix: safe.OptionalRelPath = pydantic.Field(
+ default=None,
+ description="Optional download path suffix carried to publish when automatic publish is selected",
+ )
class MaintenanceArgs(schema.Strict):
@@ -194,7 +206,6 @@ class SvnPublish(schema.Strict):
default=None,
description="Optional path suffix appended under the committee downloads directory",
)
- target_url: str = schema.description("Fully constructed SVN target URL the task will import to")
class Update(schema.Strict):
diff --git a/atr/models/results.py b/atr/models/results.py
index 95e36c13..b3d23e7c 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -228,7 +228,6 @@ class SvnPublish(schema.Strict):
kind: Literal["svn_publish"] = schema.Field(alias="kind")
svn_revision: int = schema.description("The SVN revision number that the publish landed in")
- target_url: str = schema.description("The SVN target URL the publish was sent to")
message: str = schema.description("A short status message describing the publish outcome")
diff --git a/atr/paths.py b/atr/paths.py
index 655700b2..90798e7b 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -16,14 +16,13 @@
# under the License.
import pathlib
-from typing import Final
+import urllib.parse
import atr.config as config
+import atr.constants as constants
import atr.models.safe as safe
import atr.models.sql as sql
-SVN_DOWNLOAD_URL_PREFIX: Final = "https://dist.apache.org/repos/dist/atr/"
-
def base_path_for_revision(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber
@@ -41,7 +40,10 @@ def committee_downloads_dir(committee: sql.Committee) -> safe.StatePath:
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)
- return f"{SVN_DOWNLOAD_URL_PREFIX}{relpath}"
+ 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)
diff --git a/atr/post/keys.py b/atr/post/keys.py
index beb2cc43..1057e43e 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -26,6 +26,7 @@ import asfquart.base as base
import quart
import atr.blueprints.post as post
+import atr.constants as constants
import atr.db as db
import atr.form as form
import atr.get as get
@@ -42,8 +43,6 @@ import atr.storage.types as types
import atr.util as util
import atr.web as web
-_KEYS_BASE_URL: Final[str] = "https://downloads.apache.org"
-
# The Apache Subversion KEYS file is largest at 3732091 bytes
_MAX_KEYS_SIZE: Final[int] = 10 * 1024 * 1024
@@ -270,8 +269,8 @@ 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"{_KEYS_BASE_URL}/incubator/{committee_key}/KEYS"
- return f"{_KEYS_BASE_URL}/{committee_key}/KEYS"
+ return f"{constants.DOWNLOADS_APACHE_URL}/incubator/{committee_key}/KEYS"
+ return f"{constants.DOWNLOADS_APACHE_URL}/{committee_key}/KEYS"
async def _delete_openpgp_key(
diff --git a/atr/post/voting.py b/atr/post/voting.py
index f759eafd..3c62806c 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -20,6 +20,7 @@ from __future__ import annotations
from typing import Literal
import atr.blueprints.post as post
+import atr.config as config
import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
@@ -30,6 +31,7 @@ import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
+import atr.user as user
import atr.util as util
import atr.web as web
@@ -136,6 +138,15 @@ async def selected( # noqa: C901
if notify_error is not None:
return notify_error
+ publish_error = await _publish_opt_in_error(
+ session,
+ start_voting_form,
+ vote_mode,
+ committee,
+ )
+ if publish_error is not None:
+ return publish_error
+
permitted_recipients = util.permitted_podling_first_round_recipients(
session.uid,
committee.key,
@@ -207,6 +218,12 @@ async def selected( # noqa: C901
expected_revision=start_voting_form.rendered_revision,
notify_when_finished=start_voting_form.notify_when_finished,
automatic_resolve_when_finished=start_voting_form.automatic_resolve_when_finished,
+ automatic_publish_when_resolved=start_voting_form.automatic_publish_when_resolved,
+ download_path_suffix=(
+ start_voting_form.download_path_suffix
+ if start_voting_form.automatic_publish_when_resolved
+ else None
+ ),
acknowledged_concerns=frozenset(start_voting_form.concerns_noted),
)
except storage.AccessError as e:
@@ -250,3 +267,29 @@ async def _notify_opt_in_error(
"Automatic vote resolution is not available for the first round of podling votes.",
)
return None
+
+
+async def _publish_opt_in_error(
+ session: web.Committer,
+ start_voting_form: shared.voting.StartVotingForm,
+ vote_mode: sql.VoteMode,
+ committee: sql.Committee,
+) -> web.WerkzeugResponse | None:
+ if not start_voting_form.automatic_publish_when_resolved:
+ return None
+ if not config.get().SVN_PUBLISH_URL:
+ return await session.form_error(
+ "automatic_publish_when_resolved",
+ "Automatic SVN publish is not available on this server.",
+ )
+ if vote_mode not in {sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED}:
+ return await session.form_error(
+ "automatic_publish_when_resolved",
+ "Automatic SVN publish is only available in email and Trusted Vote modes.",
+ )
+ if not user.is_committee_member(committee, session.uid):
+ return await session.form_error(
+ "automatic_publish_when_resolved",
+ "Automatic SVN publish requires a committee member initiator.",
+ )
+ return None
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index ce6d3cc0..16b0884f 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -54,6 +54,16 @@ class StartVotingForm(form.Form):
widget=form.Widget.CUSTOM,
default=False,
)
+ automatic_publish_when_resolved: form.Bool = form.label(
+ "Automatically publish to SVN when this vote resolves",
+ widget=form.Widget.CUSTOM,
+ default=False,
+ )
+ download_path_suffix: safe.OptionalRelPath = form.label(
+ "Download path suffix",
+ "Optional path under the committee downloads area.",
+ default=None,
+ )
concerns_noted: form.StrList = form.label("Concerns noted", widget=form.Widget.CUSTOM)
vote_mode: sql.VoteMode = form.label("Vote mode", widget=form.Widget.HIDDEN)
rendered_revision: safe.RevisionNumber = form.label("Rendered revision", widget=form.Widget.HIDDEN)
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index da430f72..a5db7405 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -21,7 +21,6 @@ from __future__ import annotations
import asyncio
import copy
import datetime
-import re
from typing import Final
import aiofiles.os
@@ -33,14 +32,15 @@ import atr.config as config
import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
+import atr.log as log
import atr.mail as mail
import atr.models.args as args
import atr.models.basic as basic
+import atr.models.results as results
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
import atr.storage as storage
-import atr.svn as svn
import atr.tasks.checks as checks
import atr.tasks.checks.signature as signature
import atr.util as util
@@ -198,37 +198,42 @@ class CommitteeMember(CommitteeParticipant):
raise storage.AccessError("Release already exists", status=409)
# TODO: This is not reliable because of race conditions
# But it adds a layer of protection in most cases
+ published_revision: int | None = None
+ effective_download_path_suffix = download_path_suffix
+ if config.get().SVN_PUBLISH_URL:
+ completed_publish = await interaction.release_completed_svn_publish_task_for_revision(
+ project_key,
+ release.safe_version_key,
+ preview_revision_number,
+ caller_data=self.__data,
+ )
+ if completed_publish is None:
+ log.warning(
+ f"SVN publication has not completed for {project_key!s} {version_key!s} "
+ f"{preview_revision_number!s}; checking configured target"
+ )
+ else:
+ effective_download_path_suffix = self.__download_path_suffix_from_task(completed_publish)
+ published_revision = self.__publish_revision_from_task(completed_publish)
+ if published_revision is None:
+ log.warning(
+ f"SVN publication for {project_key!s} {version_key!s} {preview_revision_number!s} "
+ "is recorded but has no revision number"
+ )
+ try:
+ target = util.svn_publish_target()
+ public_url = util.svn_publish_public_url(target, committee, effective_download_path_suffix)
+ except ValueError as exc:
+ log.warning(f"SVN publication target is not configured for {project_key!s} {version_key!s}: {exc}")
+ else:
+ await self.__warn_publication_artifacts(unfinished_path, target, public_url)
preserve = release.project.policy_preserve_download_files
if preserve is True:
- await self.__hard_link_downloads(committee, unfinished_path, download_path_suffix, dry_run=True)
+ await self.__hard_link_downloads(committee, unfinished_path, effective_download_path_suffix, dry_run=True)
# Ensure that the permissions of every directory are 755
await asyncio.to_thread(util.chmod_directories, unfinished_path)
- published_revision: int | None = None
- if svn_publish_url := config.get().SVN_PUBLISH_URL:
- svn_relpath = paths.committee_downloads_dir(committee).path.relative_to(paths.get_downloads_dir().path)
- if download_path_suffix is not None:
- svn_relpath = svn_relpath / download_path_suffix.as_path()
- target_url = f"{svn_publish_url.rstrip('/')}/{svn_relpath}"
- log_message = (
- f"Publish {project_key!s}-{version_key!s}\n\n"
- f"Committee: {committee.key}\n"
- f"Project: {project_key!s}\n"
- f"Version: {version_key!s}\n"
- "Tool: ATR"
- )
- try:
- published_revision = await svn.publish_release(
- unfinished_path.path, target_url, self.__asf_uid, log_message
- )
- except svn.CommandExecutionError as e:
- if "E160020" in e.output:
- match = re.search(r"path '([^']+)'", e.output)
- detail = f": {match.group(1)}" if match else ""
- raise storage.AccessError(f"Release file already exists in SVN{detail}", status=409) from e
- raise storage.AccessError("SVN publish failed; release was not announced", status=500) from e
-
try:
# Move the release files from somewhere in unfinished to somewhere in finished
# The whole finished hierarchy is write once for each directory, and then read only
@@ -261,7 +266,7 @@ class CommitteeMember(CommitteeParticipant):
await self.__hard_link_downloads(
committee,
finished_path,
- download_path_suffix,
+ effective_download_path_suffix,
preserve=preserve,
)
@@ -309,6 +314,39 @@ class CommitteeMember(CommitteeParticipant):
status=500,
)
+ async def __warn_publication_artifacts(
+ self,
+ unfinished_path: safe.StatePath,
+ target: util.SvnPublishTarget,
+ public_url: str,
+ ) -> None:
+ rel_paths = await self.__artifact_rel_paths(unfinished_path)
+ if not rel_paths:
+ return
+ summary = await util.check_propagation(target, public_url, rel_paths)
+ if summary.reachable != summary.total:
+ first = summary.first_failure
+ detail = first.rel_path if (first is not None) else "unknown"
+ url = first.public_url if (first is not None) else public_url
+ log.warning(
+ f"SVN publication artifact check incomplete for {public_url}; first failing path: {detail}; URL: {url}"
+ )
+
+ async def __artifact_rel_paths(self, preview_path: safe.StatePath) -> list[str]:
+ rel_paths: list[str] = []
+ async for rel in util.paths_recursive(preview_path):
+ rel_str = str(rel)
+ if analysis.is_artifact(rel_str):
+ rel_paths.append(rel_str)
+ rel_paths.sort()
+ return rel_paths[: util.MAX_PROPAGATION_ARTIFACTS]
+
+ def __download_path_suffix_from_task(self, task: sql.Task) -> safe.RelPath | None:
+ candidate = task.task_args.get("download_path_suffix")
+ if isinstance(candidate, str) and candidate:
+ return safe.RelPath(candidate)
+ return None
+
async def __hard_link_downloads(
self,
committee: sql.Committee,
@@ -332,6 +370,16 @@ class CommitteeMember(CommitteeParticipant):
dry_run=dry_run,
)
+ def __publish_revision_from_task(self, task: sql.Task) -> int | None:
+ result = task.result
+ if isinstance(result, results.SvnPublish):
+ return result.svn_revision
+ if isinstance(result, dict):
+ candidate = result.get("svn_revision")
+ if isinstance(candidate, int):
+ return candidate
+ return None
+
def __predicted_finished_release(self, release: sql.Release, release_date: datetime.datetime) -> sql.Release:
# Taking a deep copy stops this from being a SQLAlchemy proxy object
# https://docs.sqlalchemy.org/en/20/orm/session_basics.html
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 4956af8f..c01265c6 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -39,12 +39,15 @@ import atr.db.interaction as interaction
import atr.hashes as hashes
import atr.log as log
import atr.models.api as api
+import atr.models.args as args
import atr.models.attestable as attestable
+import atr.models.results as results
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
import atr.storage as storage
import atr.storage.types as types
+import atr.svn as svn
import atr.tasks.checks as checks
import atr.tasks.checks.signature as signature
import atr.util as util
@@ -453,6 +456,134 @@ class CommitteeParticipant(FoundationCommitter):
return str(e), moved_files_names, skipped_files_names
return None, moved_files_names, skipped_files_names
+ async def publish_to_svn(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ expected_revision: safe.RevisionNumber,
+ download_path_suffix: safe.RelPath | None,
+ *,
+ publisher_asf_uid: str | None = None,
+ ) -> sql.Task:
+ release = await self.__data.release(
+ project_key=str(project_key),
+ version=str(version_key),
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ _project=True,
+ _committee=True,
+ ).demand(
+ storage.AccessError(
+ f"Release preview {project_key!s} {version_key!s} does not exist",
+ status=404,
+ )
+ )
+ storage.ensure_project_active(release.project)
+ committee = release.project.committee
+ if committee is None:
+ raise storage.AccessError("Release has no committee - Invalid state", status=500)
+ latest_revision = release.safe_latest_revision_number
+ if latest_revision != expected_revision:
+ raise storage.AccessError("A newer revision appeared, please refresh and try again.", status=409)
+ task_asf_uid = publisher_asf_uid or self.__asf_uid
+ try:
+ util.svn_publish_target()
+ util.svn_publish_internal_url(committee, download_path_suffix)
+ except ValueError as exc:
+ raise storage.AccessError(f"SVN publish URL is not acceptable: {exc}", status=409) from exc
+ await self.__data.begin_immediate()
+ try:
+ existing_in_flight = await interaction.release_in_flight_svn_publish_task(
+ project_key, version_key, expected_revision, caller_data=self.__data
+ )
+ if existing_in_flight is not None:
+ raise storage.AccessError(
+ "An SVN publish task is already queued or running for this revision", status=409
+ )
+ existing_completed_any_target = await interaction.release_completed_svn_publish_task_for_revision(
+ project_key, version_key, expected_revision, caller_data=self.__data
+ )
+ if existing_completed_any_target is not None:
+ raise storage.AccessError("An SVN publish task already completed for this revision", status=409)
+ task = sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.SVN_PUBLISH,
+ task_args=args.SvnPublish(
+ asf_uid=task_asf_uid,
+ project_key=project_key,
+ version_key=version_key,
+ revision_number=expected_revision,
+ download_path_suffix=download_path_suffix,
+ ).model_dump(),
+ asf_uid=task_asf_uid,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ revision_number=str(expected_revision),
+ )
+ self.__data.add(task)
+ await self.__data.commit()
+ except storage.AccessError:
+ await self.__data.rollback()
+ raise
+ except Exception:
+ await self.__data.rollback()
+ raise
+ self.__write_as.append_to_audit_log(
+ asf_uid=task_asf_uid,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ revision_number=str(expected_revision),
+ )
+ return task
+
+ async def publish_to_svn_execute(self, task_args: args.SvnPublish) -> results.SvnPublish:
+ release = await self.__data.release(
+ project_key=str(task_args.project_key),
+ version=str(task_args.version_key),
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ _project=True,
+ _committee=True,
+ ).demand(types.FailedError("Release preview not found for publish"))
+ storage.ensure_project_active(release.project)
+ committee = release.project.committee
+ if committee is None:
+ raise types.FailedError("Release has no committee - Invalid state")
+ latest_revision = release.safe_latest_revision_number
+ if latest_revision != task_args.revision_number:
+ raise types.FailedError("A newer revision appeared after queuing this publish task")
+ try:
+ internal_url = util.svn_publish_internal_url(committee, task_args.download_path_suffix)
+ except ValueError as exc:
+ raise types.FailedError(f"SVN publish URL is not acceptable: {exc}") from exc
+ preview_path = paths.release_directory(release)
+ log_message = (
+ f"Publish {task_args.project_key!s}-{task_args.version_key!s}\n\n"
+ f"Committee: {committee.key}\n"
+ f"Project: {task_args.project_key!s}\n"
+ f"Version: {task_args.version_key!s}\n"
+ "Tool: ATR\n"
+ f"Released by {task_args.asf_uid} via ATR"
+ )
+
+ try:
+ revision = await svn.publish_release(preview_path.path, internal_url, task_args.asf_uid, log_message)
+ except svn.CommandExecutionError as exc:
+ log.error("SVN publish failed")
+ if "E160020" in exc.output:
+ message = "Release file already exists in SVN"
+ if (match := re.search(r"path '([^']+)'", exc.output)) is not None:
+ message = f"{message}: {match.group(1)}"
+ if svn_publish_url := config.get().SVN_PUBLISH_URL:
+ message = message.replace(svn_publish_url, "")
+ raise types.FailedError(message) from exc
+ raise types.FailedError("SVN publish failed; see worker logs for details") from exc
+ if revision is None:
+ raise types.FailedError("SVN publish did not return a Committed revision line")
+ return results.SvnPublish(
+ kind="svn_publish",
+ svn_revision=revision,
+ message=f"Published to SVN as r{revision}",
+ )
+
async def promote_to_candidate(
self,
release_key: safe.ReleaseKey,
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 8dfdb7e4..1b7b72f6 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -23,6 +23,7 @@ from typing import Literal
import sqlmodel
+import atr.config as config
import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
@@ -309,6 +310,9 @@ class CommitteeParticipant(FoundationCommitter):
expected_revision: safe.RevisionNumber | None = None,
notify_when_finished: bool = False,
automatic_resolve_when_finished: bool = False,
+ automatic_publish_when_resolved: bool = False,
+ automatic_publish_asf_uid: str | None = None,
+ download_path_suffix: safe.RelPath | None = None,
acknowledged_concerns: frozenset[str] = frozenset(),
) -> sql.Task:
if release is None:
@@ -371,6 +375,17 @@ class CommitteeParticipant(FoundationCommitter):
"Automatic vote resolution requires a committee member or release manager initiator",
status=403,
)
+ if automatic_publish_when_resolved:
+ if not config.get().SVN_PUBLISH_URL:
+ raise storage.AccessError("Automatic SVN publish is not available on this server", status=403)
+ if vote_mode not in {sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED}:
+ raise storage.AccessError(
+ "Automatic SVN publish is only available in email and Trusted Vote modes", status=403
+ )
+ if self.__asf_uid not in committee.committee_members:
+ 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
if permitted_recipients is None:
permitted_recipients = util.permitted_podling_first_round_recipients(
self.__asf_uid,
@@ -418,6 +433,9 @@ class CommitteeParticipant(FoundationCommitter):
second_round_email_to=second_round_email_to,
notify_when_finished=notify_when_finished,
automatic_resolve_when_finished=automatic_resolve_when_finished,
+ automatic_publish_when_resolved=automatic_publish_when_resolved,
+ automatic_publish_asf_uid=automatic_publish_asf_uid,
+ download_path_suffix=download_path_suffix,
).model_dump(),
asf_uid=self.__asf_uid,
project_key=str(project_key),
@@ -709,6 +727,20 @@ class ReleaseManager(CommitteeParticipant):
subject_data, body_data = await construct.start_vote_subject_and_body(
subject_template, body_template, options
)
+ carried_publish_when_resolved = bool(
+ latest_vote_task.task_args.get("automatic_publish_when_resolved", False)
+ )
+ carried_publish_asf_uid = latest_vote_task.task_args.get("automatic_publish_asf_uid")
+ if not isinstance(carried_publish_asf_uid, str):
+ carried_publish_asf_uid = latest_vote_task.task_args.get("initiator_id")
+ if not isinstance(carried_publish_asf_uid, str):
+ carried_publish_asf_uid = self.__asf_uid
+ carried_download_path_suffix_raw = latest_vote_task.task_args.get("download_path_suffix")
+ carried_download_path_suffix = (
+ safe.RelPath(carried_download_path_suffix_raw)
+ if isinstance(carried_download_path_suffix_raw, str)
+ else None
+ )
second_round_task = await self.start(
email_to=incubator_vote_address,
permitted_recipients=[incubator_vote_address],
@@ -723,6 +755,9 @@ class ReleaseManager(CommitteeParticipant):
expected_revision=release.safe_latest_revision_number,
automatic_resolve_when_finished=automatic_resolve_when_finished,
notify_when_finished=notify_when_finished,
+ automatic_publish_when_resolved=carried_publish_when_resolved,
+ automatic_publish_asf_uid=carried_publish_asf_uid,
+ download_path_suffix=carried_download_path_suffix,
)
second_round_vote_seq = second_round_task.task_args["vote_seq"]
if not isinstance(second_round_vote_seq, int):
@@ -752,13 +787,19 @@ class ReleaseManager(CommitteeParticipant):
success_message = "Vote marked as passed"
description = "Create a preview revision from the last candidate draft"
- await self.__write_as.revision.create_revision_with_quarantine(
+ preview_revision = await self.__write_as.revision.create_revision_with_quarantine(
project_key,
release.safe_version_key,
self.__asf_uid,
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_PREVIEW}),
description=description,
)
+ await self.__enqueue_automatic_svn_publish(
+ project_key,
+ release.safe_version_key,
+ preview_revision,
+ latest_vote_task,
+ )
if (voting_round == 2) and (release.podling_thread_id is not None):
round_one_email_address, round_one_message_id = await util.email_mid_from_thread_id(
release.podling_thread_id
@@ -1001,6 +1042,20 @@ class ReleaseManager(CommitteeParticipant):
subject_data, body_data = await construct.start_vote_subject_and_body(
subject_template, body_template, options
)
+ carried_publish_when_resolved = bool(
+ latest_vote_task.task_args.get("automatic_publish_when_resolved", False)
+ )
+ carried_publish_asf_uid = latest_vote_task.task_args.get("automatic_publish_asf_uid")
+ if not isinstance(carried_publish_asf_uid, str):
+ carried_publish_asf_uid = latest_vote_task.task_args.get("initiator_id")
+ if not isinstance(carried_publish_asf_uid, str):
+ carried_publish_asf_uid = self.__asf_uid
+ carried_download_path_suffix_raw = latest_vote_task.task_args.get("download_path_suffix")
+ carried_download_path_suffix = (
+ safe.RelPath(carried_download_path_suffix_raw)
+ if isinstance(carried_download_path_suffix_raw, str)
+ else None
+ )
second_round_task = await self.start(
email_to=incubator_vote_address,
permitted_recipients=[incubator_vote_address],
@@ -1015,6 +1070,9 @@ class ReleaseManager(CommitteeParticipant):
expected_revision=release.safe_latest_revision_number,
automatic_resolve_when_finished=automatic_resolve_when_finished,
notify_when_finished=notify_when_finished,
+ automatic_publish_when_resolved=carried_publish_when_resolved,
+ automatic_publish_asf_uid=carried_publish_asf_uid,
+ download_path_suffix=carried_download_path_suffix,
)
second_round_vote_seq = second_round_task.task_args["vote_seq"]
if not isinstance(second_round_vote_seq, int):
@@ -1059,13 +1117,19 @@ class ReleaseManager(CommitteeParticipant):
extra_destination = None
if (vote_result == "passed") and (voting_round != 1):
description = "Create a preview revision from the last candidate draft"
- await self.__write_as.revision.create_revision_with_quarantine(
+ preview_revision = await self.__write_as.revision.create_revision_with_quarantine(
project_key,
release.safe_version_key,
self.__asf_uid,
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_PREVIEW}),
description=description,
)
+ await self.__enqueue_automatic_svn_publish(
+ project_key,
+ release.safe_version_key,
+ preview_revision,
+ latest_vote_task,
+ )
if (voting_round == 2) and (release.podling_thread_id is not None):
round_one_email_address, round_one_message_id = await util.email_mid_from_thread_id(
release.podling_thread_id
@@ -1176,6 +1240,41 @@ class ReleaseManager(CommitteeParticipant):
)
await self.__data.execute(auto_resolve_stmt)
+ async def __enqueue_automatic_svn_publish(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ preview_result: sql.Revision | sql.Quarantined,
+ vote_task_with_publish_options: sql.Task | None,
+ ) -> None:
+ if not isinstance(preview_result, sql.Revision):
+ return
+ if vote_task_with_publish_options is None:
+ return
+ if not config.get().SVN_PUBLISH_URL:
+ return
+ if not bool(vote_task_with_publish_options.task_args.get("automatic_publish_when_resolved", False)):
+ return
+ try:
+ download_path_suffix_raw = vote_task_with_publish_options.task_args.get("download_path_suffix")
+ download_path_suffix = (
+ safe.RelPath(download_path_suffix_raw) if isinstance(download_path_suffix_raw, str) else None
+ )
+ publisher_asf_uid = vote_task_with_publish_options.task_args.get("automatic_publish_asf_uid")
+ if not isinstance(publisher_asf_uid, str):
+ publisher_asf_uid = vote_task_with_publish_options.task_args.get("initiator_id")
+ if not isinstance(publisher_asf_uid, str):
+ publisher_asf_uid = self.__asf_uid
+ await self.__write_as.release.publish_to_svn(
+ project_key,
+ version_key,
+ preview_result.safe_number,
+ download_path_suffix,
+ publisher_asf_uid=publisher_asf_uid,
+ )
+ except Exception as exc:
+ log.warning(f"Automatic SVN publish enqueue skipped for {project_key!s} {version_key!s}: {exc}")
+
# def __committee_member_or_admin(self, committee: sql.Committee, asf_uid: str) -> None:
# if not (user.is_committee_member(committee, asf_uid) or user.is_admin(asf_uid)):
# raise storage.AccessError("You do not have permission to perform this action")
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 0b3b7034..d2cdec49 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -51,6 +51,7 @@ import atr.tasks.metadata as metadata
import atr.tasks.quarantine as quarantine
import atr.tasks.sbom as sbom
import atr.tasks.svn as svn
+import atr.tasks.svnpub as svnpub
import atr.tasks.vote as vote
import atr.util as util
@@ -353,7 +354,7 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
case sql.TaskType.SVN_IMPORT_FILES:
return svn.import_files
case sql.TaskType.SVN_PUBLISH:
- raise NotImplementedError("The SVN_PUBLISH task handler is not yet implemented")
+ return svnpub.publish
case sql.TaskType.TARGZ_INTEGRITY:
raise ValueError("TARGZ_INTEGRITY check has been removed; quarantine extraction validates integrity")
case sql.TaskType.TARGZ_STRUCTURE:
diff --git a/atr/tasks/svn.py b/atr/tasks/svn.py
index 37f9fcf2..729ad85b 100644
--- a/atr/tasks/svn.py
+++ b/atr/tasks/svn.py
@@ -16,11 +16,12 @@
# under the License.
import asyncio
-from typing import Any, Final
+from typing import Any
import aiofiles.os
import aioshutil
+import atr.constants as constants
import atr.log as log
import atr.models.args as args
import atr.models.results as results
@@ -29,8 +30,6 @@ import atr.models.sql as sql
import atr.storage as storage
import atr.tasks.checks as checks
-_SVN_BASE_URL: Final[str] = "https://dist.apache.org/repos/dist"
-
class SvnImportError(Exception):
"""Custom exception for SVN import failures."""
@@ -95,7 +94,7 @@ async def _import_files_core(args: args.SvnImport) -> str:
"-r",
args.revision,
"--",
- f"{_SVN_BASE_URL}/{args.svn_url!s}",
+ f"{constants.SVN_DIST_ROOT_URL}/{args.svn_url!s}",
str(temp_export_path),
]
diff --git a/atr/tasks/svnpub.py b/atr/tasks/svnpub.py
new file mode 100644
index 00000000..923b2218
--- /dev/null
+++ b/atr/tasks/svnpub.py
@@ -0,0 +1,28 @@
+# 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.
+
+import atr.models.args as args
+import atr.models.results as results
+import atr.storage as storage
+import atr.tasks.checks as checks
+
+
+@checks.with_model(args.SvnPublish)
+async def publish(task_args: args.SvnPublish) -> results.Results | None:
+ async with storage.write(task_args.asf_uid) as write:
+ wacp = await write.as_project_committee_participant(task_args.project_key)
+ return await wacp.release.publish_to_svn_execute(task_args)
diff --git a/atr/util.py b/atr/util.py
index 1e3eb7c7..9082a760 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -24,6 +24,7 @@ import datetime
import email.parser
import email.policy
import email.utils
+import enum
import fcntl
import hashlib
import json
@@ -51,6 +52,7 @@ import quart
# NOTE: The atr.db module imports this module
# Therefore, this module must not import atr.db
import atr.config as config
+import atr.constants as constants
import atr.ldap as ldap
import atr.log as log
import atr.models.safe as safe
@@ -96,8 +98,7 @@ PRIVATE_KEY_UPLOAD_WARNING: Final[str] = (
)
USER_TESTS_ADDRESS: Final[str] = "user-tests@tooling.apache.org"
LISTS_APACHE_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
-SVN_PUBLISH_PATH_PRODUCTION: Final[str] = "/repos/dist/release"
-SVN_PUBLISH_PATH_TEST: Final[str] = "/repos/dist/atr"
+MAX_PROPAGATION_ARTIFACTS: Final[int] = 24
EXPECTED_DEFAULT_TLS_CHECK_HOSTNAME: Final[bool] = True
EXPECTED_DEFAULT_TLS_MINIMUM_VERSION: Final[ssl.TLSVersion] = ssl.TLSVersion.TLSv1_2
EXPECTED_DEFAULT_TLS_VERIFY_MODE: Final[ssl.VerifyMode] = ssl.CERT_REQUIRED
@@ -142,6 +143,35 @@ class ConcernGroup:
count: int
+@dataclasses.dataclass(frozen=True)
+class PropagationOutcome:
+ rel_path: str
+ public_url: str
+ ok: bool
+ status: int | None
+ error: str | None
+
+
+@dataclasses.dataclass(frozen=True)
+class PropagationSummary:
+ target: "SvnPublishTarget"
+ total: int
+ reachable: int
+ outcomes: list[PropagationOutcome]
+
+ @property
+ def first_failure(self) -> PropagationOutcome | None:
+ for outcome in self.outcomes:
+ if not outcome.ok:
+ return outcome
+ return None
+
+
+class SvnPublishTarget(enum.Enum):
+ ATR = "atr"
+ RELEASE = "release"
+
+
class EmailRecipients(Protocol):
email_to: str
email_cc: list[str]
@@ -266,6 +296,25 @@ async def atomic_write_file(file_path: pathlib.Path, content: str, encoding: str
raise
+async def check_propagation(
+ target: SvnPublishTarget,
+ public_base_url: str,
+ rel_paths: Sequence[str],
+) -> PropagationSummary:
+ capped = list(rel_paths[:MAX_PROPAGATION_ARTIFACTS])
+ if not capped:
+ return PropagationSummary(target=target, total=0, reachable=0, outcomes=[])
+ async with create_secure_session(timeout=LISTS_APACHE_TIMEOUT) as http_session:
+ outcomes = await asyncio.gather(
+ *[
+ _propagation_probe(http_session, rel_path, _propagation_public_url(public_base_url, rel_path))
+ for rel_path in capped
+ ]
+ )
+ reachable = sum(1 for outcome in outcomes if outcome.ok)
+ return PropagationSummary(target=target, total=len(outcomes), reachable=reachable, outcomes=outcomes)
+
+
def checker_display_name(checker: str) -> str:
return checker.removeprefix("atr.tasks.checks.").replace("_", " ").replace(".", " ").title()
@@ -1011,23 +1060,7 @@ def submitted_concerns_from_flash(flash_data: dict[str, Any]) -> list[str]:
return []
-def svn_production_target_url(url: str) -> bool:
- parsed = urllib.parse.urlparse(url)
- if parsed.scheme not in {"http", "https"}:
- raise ValueError(f"SVN_PUBLISH_URL must use http or https, got {parsed.scheme!r}")
- if not parsed.netloc:
- raise ValueError("SVN_PUBLISH_URL must have a host")
- path = parsed.path.rstrip("/")
- if (path == SVN_PUBLISH_PATH_PRODUCTION) or path.startswith(SVN_PUBLISH_PATH_PRODUCTION + "/"):
- return True
- if (path == SVN_PUBLISH_PATH_TEST) or path.startswith(SVN_PUBLISH_PATH_TEST + "/"):
- return False
- raise ValueError(
- f"SVN_PUBLISH_URL path must be under {SVN_PUBLISH_PATH_PRODUCTION} or {SVN_PUBLISH_PATH_TEST}, got {path!r}"
- )
-
-
-def svn_publish_target_url(
+def svn_publish_internal_url(
committee: sql.Committee,
download_path_suffix: safe.RelPath | None,
) -> str:
@@ -1040,6 +1073,31 @@ def svn_publish_target_url(
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"):
+ return SvnPublishTarget.ATR
+ if public_path.endswith("/release"):
+ return SvnPublishTarget.RELEASE
+ raise ValueError("SVN_DIST_PUBLIC_URL must be an atr or release dist URL")
+
+
async def task_archive_url(task_mid: str, recipient: str | None = None) -> str | None:
try:
return await task_archive_url_strict(task_mid, recipient)
@@ -1483,6 +1541,36 @@ def _npm_pack_parse_package_json(raw: bytes) -> tuple[dict[str, Any] | None, str
return parsed, None
+async def _propagation_probe(
+ http_session: aiohttp.ClientSession,
+ rel_path: str,
+ public_url: str,
+) -> PropagationOutcome:
+ try:
+ async with http_session.head(public_url, allow_redirects=True) as resp:
+ status = resp.status
+ ok = 200 <= status < 300
+ return PropagationOutcome(
+ rel_path=rel_path,
+ public_url=public_url,
+ ok=ok,
+ status=status,
+ error=None if ok else f"HTTP {status}",
+ )
+ except aiohttp.ClientError as exc:
+ return PropagationOutcome(rel_path=rel_path, public_url=public_url, ok=False, status=None, error=str(exc))
+ except TimeoutError as exc:
+ return PropagationOutcome(
+ rel_path=rel_path, public_url=public_url, ok=False, status=None, error=f"timeout: {exc}"
+ )
+
+
+def _propagation_public_url(public_base_url: str, rel_path: str) -> str:
+ base = public_base_url.rstrip("/")
+ quoted = urllib.parse.quote(rel_path)
+ return f"{base}/{quoted}"
+
+
def _thread_message_from_source(mid: str, content: bytes) -> dict[str, Any]:
parser = email.parser.BytesParser(policy=email.policy.default)
message = parser.parsebytes(content)
diff --git a/tests/unit/test_svn_publish_announce.py b/tests/unit/test_svn_publish_announce.py
new file mode 100644
index 00000000..4acd7fe3
--- /dev/null
+++ b/tests/unit/test_svn_publish_announce.py
@@ -0,0 +1,64 @@
+# 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.
+
+import pathlib
+
+import pytest
+
+import atr.constants as constants
+import atr.models.safe as safe
+import atr.storage.writers.announce as announce_writer
+import atr.util as util
+
+
+async def fake_check_propagation(
+ target: util.SvnPublishTarget,
+ public_url: str,
+ rel_paths: list[str],
+) -> util.PropagationSummary:
+ return util.PropagationSummary(
+ target=target,
+ total=1,
+ reachable=0,
+ outcomes=[
+ util.PropagationOutcome(
+ rel_path=rel_paths[0],
+ public_url=f"{public_url}/{rel_paths[0]}",
+ ok=False,
+ status=404,
+ error="HTTP 404",
+ )
+ ],
+ )
+
+
+async def test_announce_publication_artifact_check_warns_for_unreachable_artifact(
+ tmp_path: pathlib.Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ artifact = tmp_path / "artifact.tar.gz"
+ artifact.write_text("content", encoding="utf-8")
+
+ monkeypatch.setattr(announce_writer.util, "check_propagation", fake_check_propagation)
+ warnings: list[str] = []
+ monkeypatch.setattr(announce_writer.log, "warning", warnings.append)
+ writer = object.__new__(announce_writer.CommitteeMember)
+ check = getattr(writer, "_CommitteeMember__warn_publication_artifacts")
+
+ await check(safe.StatePath(tmp_path), util.SvnPublishTarget.RELEASE, f"{constants.DOWNLOADS_APACHE_URL}/project")
+
+ assert any("artifact.tar.gz" in warning for warning in warnings)
diff --git a/tests/unit/test_svn_publish_helpers.py b/tests/unit/test_svn_publish_helpers.py
index 2cbd4f19..76d79af2 100644
--- a/tests/unit/test_svn_publish_helpers.py
+++ b/tests/unit/test_svn_publish_helpers.py
@@ -18,6 +18,7 @@
import pytest
import atr.config as config
+import atr.constants as constants
import atr.models.args as args
import atr.models.results as results
import atr.models.safe as safe
@@ -25,19 +26,76 @@ import atr.models.sql as sql
import atr.util as util
-def test_svn_production_target_url() -> None:
- assert util.svn_production_target_url("https://dist.apache.org/repos/dist/release/") is True
- assert util.svn_production_target_url("https://dist.apache.org/repos/dist/atr/") is False
+class FakeResponse:
+ def __init__(self, status: int) -> None:
+ self.status = status
+ async def __aenter__(self) -> "FakeResponse":
+ return self
-def test_svn_production_target_url_rejects_unsupported_urls() -> None:
- for url in (
- "svn://dist.apache.org/repos/dist/atr",
- "https://dist.apache.org/repos/dist/dev/",
- "https://example.invalid/somewhere",
- ):
- with pytest.raises(ValueError):
- util.svn_production_target_url(url)
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
+ return None
+
+
+class FakeSession:
+ def __init__(self, head_status: int) -> None:
+ self.head_status = head_status
+ self.head_urls: list[str] = []
+
+ async def __aenter__(self) -> "FakeSession":
+ return self
+
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
+ return None
+
+ def head(self, url: str, **kwargs: object) -> FakeResponse:
+ self.head_urls.append(url)
+ return FakeResponse(self.head_status)
+
+
+async def test_check_propagation_production_public_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = FakeSession(200)
+ monkeypatch.setattr(util, "create_secure_session", lambda timeout=None: session)
+
+ summary = await util.check_propagation(
+ util.SvnPublishTarget.RELEASE,
+ f"{constants.DOWNLOADS_APACHE_URL}/project",
+ ["artifact.tar.gz"],
+ )
+
+ assert summary.reachable == 1
+ assert summary.outcomes[0].public_url == f"{constants.DOWNLOADS_APACHE_URL}/project/artifact.tar.gz"
+
+
+async def test_check_propagation_test_target_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ session = FakeSession(200)
+ monkeypatch.setattr(util, "create_secure_session", lambda timeout=None: session)
+
+ summary = await util.check_propagation(
+ util.SvnPublishTarget.ATR,
+ f"{constants.SVN_DIST_PUBLIC_URL}/project",
+ ["artifact.tar.gz"],
+ )
+
+ assert summary.reachable == 1
+ assert summary.outcomes[0].public_url == f"{constants.SVN_DIST_PUBLIC_URL}/project/artifact.tar.gz"
+
+
+def test_svn_publish_internal_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://internal.example.invalid/repos/dist/atr")
+ committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+ target_url = util.svn_publish_internal_url(committee, safe.RelPath("apple-1.0"))
+
+ assert target_url == "https://internal.example.invalid/repos/dist/atr/apple/apple-1.0"
+
+
+def test_svn_publish_internal_url_rejects_missing_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", None)
+ committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+ with pytest.raises(ValueError):
+ util.svn_publish_internal_url(committee, None)
def test_svn_publish_models_round_trip() -> None:
@@ -46,12 +104,10 @@ def test_svn_publish_models_round_trip() -> None:
project_key="apple",
version_key="1.0",
revision_number="00001",
- target_url="https://dist.apache.org/repos/dist/atr/apple",
)
result = results.SvnPublish(
kind="svn_publish",
svn_revision=12345,
- target_url=task_args.target_url,
message="ok",
)
restored = results.ResultsAdapter.validate_python(result.model_dump())
@@ -63,18 +119,22 @@ def test_svn_publish_models_round_trip() -> None:
assert sql.TaskType.SVN_PUBLISH.label == "SVN publish"
-def test_svn_publish_target_url(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://dist.apache.org/repos/dist/atr")
+def test_svn_publish_public_url() -> None:
committee = sql.Committee(key="apple", name="Apple", is_podling=False)
- target_url = util.svn_publish_target_url(committee, safe.RelPath("apple-1.0"))
+ target_url = util.svn_publish_public_url(util.SvnPublishTarget.ATR, committee, safe.RelPath("apple-1.0"))
- assert target_url == "https://dist.apache.org/repos/dist/atr/apple/apple-1.0"
+ assert target_url == f"{constants.SVN_DIST_PUBLIC_URL}/apple/apple-1.0"
-def test_svn_publish_target_url_rejects_missing_url(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", None)
- committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+def test_svn_publish_target_from_public_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(constants, "SVN_DIST_PUBLIC_URL", "https://dist.apache.org/repos/dist/release")
+
+ assert util.svn_publish_target() == util.SvnPublishTarget.RELEASE
+
+
+def test_svn_publish_target_rejects_unknown_public_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(constants, "SVN_DIST_PUBLIC_URL", "https://dist.apache.org/repos/dist/dev")
with pytest.raises(ValueError):
- util.svn_publish_target_url(committee, None)
+ util.svn_publish_target()
diff --git a/tests/unit/test_svn_publish_start_vote.py b/tests/unit/test_svn_publish_start_vote.py
new file mode 100644
index 00000000..2632cd09
--- /dev/null
+++ b/tests/unit/test_svn_publish_start_vote.py
@@ -0,0 +1,127 @@
+# 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.
+
+import unittest.mock as mock
+from types import SimpleNamespace
+from typing import Final
+
+import atr.config as config
+import atr.models.args as args
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.post.voting as voting_post
+import atr.shared.voting as voting_shared
+import atr.storage.writers.vote as vote_writer
+
+INTERNAL_PUBLISH_URL: Final[str] = "https://internal.example.invalid/repos/dist/atr"
+
+
+async def test_automatic_publish_uses_carried_original_initiator(monkeypatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ release_writer = SimpleNamespace(publish_to_svn=mock.AsyncMock())
+ write_as = SimpleNamespace(release=release_writer)
+ writer = object.__new__(vote_writer.ReleaseManager)
+ writer._ReleaseManager__write_as = write_as
+ writer._ReleaseManager__asf_uid = "resolver"
+ task = sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.VOTE_INITIATE,
+ task_args={
+ "automatic_publish_when_resolved": True,
+ "automatic_publish_asf_uid": "alice",
+ "initiator_id": "resolver",
+ "download_path_suffix": "example-1.0",
+ },
+ asf_uid="resolver",
+ )
+ preview_revision = sql.Revision(number="00001", seq=1, release_key="example-1.0", asfuid="resolver")
+
+ enqueue = getattr(writer, "_ReleaseManager__enqueue_automatic_svn_publish")
+ await enqueue(
+ safe.ProjectKey("example"),
+ safe.VersionKey("1.0"),
+ preview_revision,
+ task,
+ )
+
+ release_writer.publish_to_svn.assert_awaited_once()
+ assert release_writer.publish_to_svn.await_args.kwargs["publisher_asf_uid"] == "alice"
+
+
+async def test_email_vote_can_opt_in_to_automatic_publish(monkeypatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ monkeypatch.setattr(voting_post.user, "is_committee_member", lambda _committee, _uid: True)
+ form = voting_shared.StartVotingForm.model_validate(
+ {
+ "csrf_token": "token",
+ "email_to": "dev@example.apache.org",
+ "vote_duration": 72,
+ "subject": "[VOTE] Release example 1.0",
+ "subject_template_hash": "abc",
+ "body": "Please vote.",
+ "automatic_publish_when_resolved": "on",
+ "download_path_suffix": "example-1.0",
+ "vote_mode": "email",
+ "rendered_revision": "00001",
+ }
+ )
+ session = type("Session", (), {"uid": "alice"})()
+ committee = sql.Committee(key="example", name="Example", is_podling=False)
+
+ assert await voting_post._publish_opt_in_error(session, form, sql.VoteMode.EMAIL, committee) is None
+
+
+def test_initiate_args_carry_publish_fields() -> None:
+ payload = args.Initiate(
+ release_key="example-1.0",
+ email_to="dev@example.apache.org",
+ vote_duration=72,
+ initiator_id="alice",
+ initiator_fullname="Alice",
+ subject="[VOTE] Release example 1.0",
+ body="Please vote.",
+ vote_seq=1,
+ automatic_publish_when_resolved=True,
+ download_path_suffix="example-1.0",
+ )
+ json_dump = payload.model_dump(mode="json")
+ restored = args.Initiate.model_validate(json_dump)
+
+ assert json_dump["automatic_publish_when_resolved"] is True
+ assert json_dump["download_path_suffix"] == "example-1.0"
+ assert restored.automatic_publish_when_resolved is True
+ assert str(restored.download_path_suffix) == "example-1.0"
+
+
+def test_start_voting_form_publish_on() -> None:
+ parsed = voting_shared.StartVotingForm.model_validate(
+ {
+ "csrf_token": "token",
+ "email_to": "dev@example.apache.org",
+ "vote_duration": 72,
+ "subject": "[VOTE] Release example 1.0",
+ "subject_template_hash": "abc",
+ "body": "Please vote.",
+ "automatic_publish_when_resolved": "on",
+ "download_path_suffix": "example-1.0",
+ "vote_mode": "trusted",
+ "rendered_revision": "00001",
+ }
+ )
+
+ assert parsed.automatic_publish_when_resolved is True
+ assert str(parsed.download_path_suffix) == "example-1.0"
diff --git a/tests/unit/test_svn_publish_tasks_db.py b/tests/unit/test_svn_publish_tasks_db.py
index 19167a86..49d2008e 100644
--- a/tests/unit/test_svn_publish_tasks_db.py
+++ b/tests/unit/test_svn_publish_tasks_db.py
@@ -29,8 +29,6 @@ import atr.db.interaction as interaction
import atr.models.safe as safe
import atr.models.sql as sql
-TARGET_URL: Final[str] = "https://dist.apache.org/repos/dist/atr/project"
-OTHER_URL: Final[str] = "https://dist.apache.org/repos/dist/atr/other"
PROJECT: Final[safe.ProjectKey] = safe.ProjectKey("project")
VERSION: Final[safe.VersionKey] = safe.VersionKey("1.0.0")
REVISION: Final[safe.RevisionNumber] = safe.RevisionNumber("00001")
@@ -54,19 +52,16 @@ async def test_completed_publish_for_revision_returns_latest_valid_result(sqlite
async with sqlite_sessionmaker() as data:
data.add(
_publish_task(
- target_url=TARGET_URL,
status=sql.TaskStatus.COMPLETED,
added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
)
)
malformed = _publish_task(
- target_url=OTHER_URL,
status=sql.TaskStatus.COMPLETED,
added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
)
malformed.result = {
"kind": "svn_publish",
- "target_url": OTHER_URL,
"message": "missing revision",
}
data.add(malformed)
@@ -77,28 +72,25 @@ async def test_completed_publish_for_revision_returns_latest_valid_result(sqlite
)
assert result is not None
- assert result.task_args["target_url"] == TARGET_URL
+ assert "target_url" not in result.task_args
async def test_in_flight_publish_returns_newest_queued_or_active_task(sqlite_sessionmaker) -> None:
async with sqlite_sessionmaker() as data:
data.add(
_publish_task(
- target_url=OTHER_URL,
status=sql.TaskStatus.ACTIVE,
added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
)
)
data.add(
_publish_task(
- target_url=TARGET_URL,
status=sql.TaskStatus.QUEUED,
added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
)
)
data.add(
_publish_task(
- target_url=TARGET_URL,
status=sql.TaskStatus.FAILED,
added=datetime.datetime(2026, 5, 3, tzinfo=datetime.UTC),
)
@@ -106,37 +98,28 @@ async def test_in_flight_publish_returns_newest_queued_or_active_task(sqlite_ses
await data.commit()
latest = await interaction.release_in_flight_svn_publish_task(PROJECT, VERSION, REVISION, caller_data=data)
- other_target = await interaction.release_in_flight_svn_publish_task(
- PROJECT, VERSION, REVISION, OTHER_URL, caller_data=data
- )
assert latest is not None
- assert latest.task_args["target_url"] == TARGET_URL
- assert other_target is not None
- assert other_target.status == sql.TaskStatus.ACTIVE
+ assert latest.status == sql.TaskStatus.QUEUED
async def test_latest_failed_publish_returns_failed_task(sqlite_sessionmaker) -> None:
async with sqlite_sessionmaker() as data:
data.add(
_publish_task(
- target_url=TARGET_URL,
status=sql.TaskStatus.QUEUED,
added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
)
)
data.add(
_publish_task(
- target_url=TARGET_URL,
status=sql.TaskStatus.FAILED,
added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
)
)
await data.commit()
- result = await interaction.release_latest_failed_svn_publish_task(
- PROJECT, VERSION, REVISION, TARGET_URL, caller_data=data
- )
+ result = await interaction.release_latest_failed_svn_publish_task(PROJECT, VERSION, REVISION, caller_data=data)
assert result is not None
assert result.status == sql.TaskStatus.FAILED
@@ -144,7 +127,6 @@ async def test_latest_failed_publish_returns_failed_task(sqlite_sessionmaker) ->
def _publish_task(
*,
- target_url: str,
status: sql.TaskStatus,
added: datetime.datetime,
revision_number: str = "00001",
@@ -164,7 +146,6 @@ def _publish_task(
result = {
"kind": "svn_publish",
"svn_revision": 42,
- "target_url": target_url,
"message": "ok",
}
elif status == sql.TaskStatus.FAILED:
@@ -179,7 +160,6 @@ def _publish_task(
"version_key": version_key,
"revision_number": revision_number,
"download_path_suffix": None,
- "target_url": target_url,
},
asf_uid="alice",
project_key=project_key,
diff --git a/tests/unit/test_svn_publish_writer.py b/tests/unit/test_svn_publish_writer.py
new file mode 100644
index 00000000..a615737f
--- /dev/null
+++ b/tests/unit/test_svn_publish_writer.py
@@ -0,0 +1,319 @@
+# 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.
+
+import datetime
+import unittest.mock as mock
+from collections.abc import AsyncIterator
+from types import SimpleNamespace
+from typing import Final
+
+import pytest
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.config as config
+import atr.db as db
+import atr.models.args as args
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.types as storage_types
+import atr.storage.writers.release as release_writer
+
+INTERNAL_PUBLISH_URL: Final[str] = "https://internal.example.invalid/repos/dist/atr"
+
+
+@pytest.fixture
+async def sqlite_sessionmaker() -> AsyncIterator[sqlalchemy.ext.asyncio.async_sessionmaker[db.Session]]:
+ engine = sqlalchemy.ext.asyncio.create_async_engine(
+ "sqlite+aiosqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=sqlalchemy.pool.StaticPool,
+ )
+ async with engine.begin() as conn:
+ await conn.run_sync(sqlmodel.SQLModel.metadata.create_all)
+ sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+ yield sessionmaker
+ await engine.dispose()
+
+
+async def test_publish_to_svn_allows_retry_after_failed_task(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ data.add(_publish_task(status=sql.TaskStatus.FAILED))
+ await data.commit()
+ writer = _release_writer(data)
+
+ task = await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+ assert task.status == sql.TaskStatus.QUEUED
+
+
+async def test_publish_to_svn_allows_retry_after_malformed_completed_task(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ task = _publish_task(status=sql.TaskStatus.COMPLETED)
+ task.result = {
+ "kind": "svn_publish",
+ "message": "missing revision",
+ }
+ data.add(task)
+ await data.commit()
+ writer = _release_writer(data)
+
+ retry = await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+ assert retry.status == sql.TaskStatus.QUEUED
+
+
+async def test_publish_to_svn_enqueues_task(sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ writer = _release_writer(data)
+
+ task = await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ safe.RelPath("project-1.0.0"),
+ )
+
+ assert task.task_type == sql.TaskType.SVN_PUBLISH
+ assert task.asf_uid == "alice"
+ assert task.revision_number == "00001"
+ assert "target_url" not in task.task_args
+
+
+async def test_publish_to_svn_execute_maps_existing_svn_path(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ publish_release = mock.AsyncMock(
+ side_effect=release_writer.svn.CommandExecutionError(1, "svn: E160020: path '/project/file' already exists")
+ )
+ monkeypatch.setattr(release_writer.svn, "publish_release", publish_release)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ writer = _release_writer(data)
+
+ with pytest.raises(storage_types.FailedError, match="Release file already exists in SVN"):
+ await writer.publish_to_svn_execute(
+ args.SvnPublish(
+ asf_uid="alice",
+ project_key="project",
+ version_key="1.0.0",
+ revision_number="00001",
+ )
+ )
+
+
+async def test_publish_to_svn_execute_returns_result(sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ publish_release = mock.AsyncMock(return_value=12345)
+ monkeypatch.setattr(release_writer.svn, "publish_release", publish_release)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ writer = _release_writer(data)
+
+ result = await writer.publish_to_svn_execute(
+ args.SvnPublish(
+ asf_uid="alice",
+ project_key="project",
+ version_key="1.0.0",
+ revision_number="00001",
+ download_path_suffix="project-1.0.0",
+ )
+ )
+
+ assert isinstance(result, results.SvnPublish)
+ assert result.svn_revision == 12345
+ assert "internal.example.invalid" not in result.message
+ publish_release.assert_awaited_once()
+ assert publish_release.await_args.args[1] == f"{INTERNAL_PUBLISH_URL}/project/project-1.0.0"
+
+
+async def test_publish_to_svn_rejects_duplicate_completed_task(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ data.add(_publish_task(status=sql.TaskStatus.COMPLETED))
+ await data.commit()
+ writer = _release_writer(data)
+
+ with pytest.raises(storage.AccessError, match="already completed"):
+ await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+
+async def test_publish_to_svn_rejects_duplicate_completed_task_after_target_drift(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(
+ config.get(),
+ "SVN_PUBLISH_URL",
+ "https://internal.example.invalid/repos/dist/release",
+ raising=False,
+ )
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ data.add(_publish_task(status=sql.TaskStatus.COMPLETED))
+ await data.commit()
+ writer = _release_writer(data)
+
+ with pytest.raises(storage.AccessError, match="already completed"):
+ await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+
+async def test_publish_to_svn_rejects_duplicate_queued_task(
+ sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ writer = _release_writer(data)
+ await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+ with pytest.raises(storage.AccessError, match="already queued or running"):
+ await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ None,
+ )
+
+
+async def test_publish_to_svn_rejects_stale_revision(sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
+ async with sqlite_sessionmaker() as data:
+ await _seed_preview_release(data)
+ writer = _release_writer(data)
+
+ with pytest.raises(storage.AccessError, match="newer revision"):
+ await writer.publish_to_svn(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00009"),
+ None,
+ )
+
+
+def _publish_task(*, status: sql.TaskStatus) -> sql.Task:
+ completed: datetime.datetime | None = None
+ error: str | None = None
+ result: dict[str, object] | None = None
+ if status == sql.TaskStatus.COMPLETED:
+ completed = datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC)
+ result = {
+ "kind": "svn_publish",
+ "svn_revision": 42,
+ "message": "ok",
+ }
+ elif status == sql.TaskStatus.FAILED:
+ completed = datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC)
+ error = "publish failed"
+ return sql.Task(
+ status=status,
+ task_type=sql.TaskType.SVN_PUBLISH,
+ task_args={
+ "asf_uid": "alice",
+ "project_key": "project",
+ "version_key": "1.0.0",
+ "revision_number": "00001",
+ "download_path_suffix": None,
+ },
+ asf_uid="alice",
+ project_key="project",
+ version_key="1.0.0",
+ revision_number="00001",
+ added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
+ completed=completed,
+ result=result,
+ error=error,
+ )
+
+
+def _release_writer(data: db.Session) -> release_writer.CommitteeParticipant:
+ writer = object.__new__(release_writer.CommitteeParticipant)
+ writer._CommitteeParticipant__asf_uid = "alice"
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__write_as = SimpleNamespace(append_to_audit_log=mock.Mock())
+ return writer
+
+
+async def _seed_preview_release(data: db.Session) -> None:
+ committee = sql.Committee(
+ key="project",
+ name="Project",
+ is_podling=False,
+ committee_members=["alice"],
+ committers=["alice"],
+ )
+ project = sql.Project(key="project", name="Project", committee=committee)
+ release = sql.Release(
+ key="project-1.0.0",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ project=project,
+ project_key=project.key,
+ version="1.0.0",
+ created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+ )
+ revision = sql.Revision(
+ key="project-1.0.0 00001",
+ release=release,
+ release_key=release.key,
+ seq=1,
+ number="00001",
+ asfuid="alice",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ )
+ data.add_all([committee, project, release, revision])
+ await data.commit()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org