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/25 19:28:40 UTC

(tooling-trusted-releases) branch sbp updated: Add code to support an SVN publication task

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


The following commit(s) were added to refs/heads/sbp by this push:
     new 43de5880 Add code to support an SVN publication task
43de5880 is described below

commit 43de588069c1c491da3b597c0619949678f723e7
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon May 25 20:26:01 2026 +0100

    Add code to support an SVN publication task
---
 atr/db/interaction.py                   |  89 +++++++++++++++
 atr/models/args.py                      |  14 +++
 atr/models/results.py                   |  10 ++
 atr/models/sql.py                       |   3 +
 atr/storage/writers/release.py          |  12 +-
 atr/tasks/__init__.py                   |   2 +
 atr/util.py                             |  31 +++++
 tests/unit/test_svn_publish_helpers.py  |  80 +++++++++++++
 tests/unit/test_svn_publish_tasks_db.py | 194 ++++++++++++++++++++++++++++++++
 9 files changed, 429 insertions(+), 6 deletions(-)

diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index cb4f9541..48ec823b 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -23,6 +23,7 @@ from typing import Final
 
 import asfquart.base as base
 import packaging.version as version
+import pydantic
 import sqlalchemy
 import sqlalchemy.orm as orm
 import sqlmodel
@@ -542,6 +543,30 @@ async def prior_release_for_archive(project: sql.Project, version: str) -> sql.R
     return cycles.prior_release_in_cycle(project, version, candidates)
 
 
+async def release_completed_svn_publish_task_for_revision(
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision_number: safe.RevisionNumber,
+    caller_data: db.Session | None = None,
+) -> sql.Task | None:
+    """Return the most recently completed usable SVN_PUBLISH task for the revision."""
+    via = sql.validate_instrumented_attribute
+    async with db.ensure_session(caller_data) as data:
+        query = (
+            sqlmodel.select(sql.Task)
+            .where(sql.Task.project_key == str(project_key))
+            .where(sql.Task.version_key == str(version_key))
+            .where(sql.Task.revision_number == str(revision_number))
+            .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+            .where(sql.Task.status == sql.TaskStatus.COMPLETED)
+            .order_by(via(sql.Task.added).desc())
+        )
+        for task in (await data.execute(query)).scalars().all():
+            if _svn_publish_result(task) is not None:
+                return task
+        return None
+
+
 async def release_current_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
     current_vote_seq = getattr(release, "current_vote_seq", None)
     if current_vote_seq is None:
@@ -561,6 +586,56 @@ async def release_current_vote_task(release: sql.Release, caller_data: db.Sessio
         return task
 
 
+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."""
+    via = sql.validate_instrumented_attribute
+    async with db.ensure_session(caller_data) as data:
+        query = (
+            sqlmodel.select(sql.Task)
+            .where(sql.Task.project_key == str(project_key))
+            .where(sql.Task.version_key == str(version_key))
+            .where(sql.Task.revision_number == str(revision_number))
+            .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+            .where(via(sql.Task.status).in_([sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]))
+            .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()
+
+
+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."""
+    via = sql.validate_instrumented_attribute
+    async with db.ensure_session(caller_data) as data:
+        query = (
+            sqlmodel.select(sql.Task)
+            .where(sql.Task.project_key == str(project_key))
+            .where(sql.Task.version_key == str(version_key))
+            .where(sql.Task.revision_number == str(revision_number))
+            .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+            .where(sql.Task.status == sql.TaskStatus.FAILED)
+            .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()
+
+
 async def release_latest_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
     via = sql.validate_instrumented_attribute
@@ -956,6 +1031,20 @@ async def wait_for_task(
     return False
 
 
+def _svn_publish_result(task: sql.Task) -> results.SvnPublish | None:
+    result = task.result
+    if isinstance(result, results.SvnPublish):
+        return result
+    if isinstance(result, dict):
+        try:
+            parsed = results.ResultsAdapter.validate_python(result)
+        except pydantic.ValidationError:
+            return None
+        if isinstance(parsed, results.SvnPublish):
+            return parsed
+    return None
+
+
 async def _trusted_ballot_details_from_ballots(
     release: sql.Release,
     ballots: Sequence[sql.BallotPaper],
diff --git a/atr/models/args.py b/atr/models/args.py
index f5909e40..8093c884 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -183,6 +183,20 @@ class SvnImport(schema.Strict):
     asf_uid: str
 
 
+class SvnPublish(schema.Strict):
+    """Arguments for the task to publish a release preview to SVN."""
+
+    asf_uid: str = schema.description("ASF UID of the user initiating publication")
+    project_key: safe.ProjectKey = schema.description("Project key in ATR")
+    version_key: safe.VersionKey = schema.description("Version key in ATR")
+    revision_number: safe.RevisionNumber = schema.description("Preview revision number to publish")
+    download_path_suffix: safe.OptionalRelPath = pydantic.Field(
+        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):
     """Arguments for the task to update metadata from remote data sources."""
 
diff --git a/atr/models/results.py b/atr/models/results.py
index 8a92a590..95e36c13 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -223,6 +223,15 @@ class SvnImportFiles(schema.Strict):
     msg: str = schema.description("The message from the SVN import")
 
 
+class SvnPublish(schema.Strict):
+    """Result of the task to publish a release preview to SVN."""
+
+    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")
+
+
 class VoteAutoResolve(schema.Strict):
     """Result of the task to automatically resolve a vote."""
 
@@ -285,6 +294,7 @@ Results = Annotated[
     | SBOMQsScore
     | SBOMToolScore
     | SvnImportFiles
+    | SvnPublish
     | VoteAutoResolve
     | VoteEndNotify
     | VoteInitiate,
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5e4e4d92..ff6e28c5 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -228,6 +228,7 @@ class TaskType(enum.StrEnum):
     SBOM_TOOL_SCORE = "sbom_tool_score"
     SIGNATURE_CHECK = "signature_check"
     SVN_IMPORT_FILES = "svn_import_files"
+    SVN_PUBLISH = "svn_publish"
     TARGZ_INTEGRITY = "targz_integrity"
     TARGZ_STRUCTURE = "targz_structure"
     VOTE_AUTO_RESOLVE = "vote_auto_resolve"
@@ -282,6 +283,8 @@ class TaskType(enum.StrEnum):
                 return "Signature check"
             case TaskType.SVN_IMPORT_FILES:
                 return "SVN import"
+            case TaskType.SVN_PUBLISH:
+                return "SVN publish"
             case TaskType.TARGZ_INTEGRITY:
                 return "Targz integrity"
             case TaskType.TARGZ_STRUCTURE:
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 5b7bba89..4956af8f 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -746,9 +746,9 @@ class CommitteeParticipant(FoundationCommitter):
                 status=400,
             )
 
-    async def upload_file(self, args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
-        file_bytes = base64.b64decode(args.content, validate=True)
-        validated_path = args.relpath.as_path()
+    async def upload_file(self, upload_args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
+        file_bytes = base64.b64decode(upload_args.content, validate=True)
+        validated_path = upload_args.relpath.as_path()
         description = f"Upload via API: {validated_path}"
 
         async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
@@ -758,8 +758,8 @@ class CommitteeParticipant(FoundationCommitter):
                 await f.write(file_bytes)
 
         result = await self.__write_as.revision.create_revision_with_quarantine(
-            args.project,
-            args.version,
+            upload_args.project,
+            upload_args.version,
             self.__asf_uid,
             allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
             description=description,
@@ -768,7 +768,7 @@ class CommitteeParticipant(FoundationCommitter):
         if isinstance(result, sql.Quarantined):
             return result
         async with db.session() as data:
-            release_key = sql.release_key(args.project, args.version)
+            release_key = sql.release_key(upload_args.project, upload_args.version)
             return await data.revision(
                 release_key=str(release_key),
                 number=result.number,
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index b8ce956b..0b3b7034 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -352,6 +352,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
             return signature.check
         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")
         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/util.py b/atr/util.py
index 5ae12cad..1e3eb7c7 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -96,6 +96,8 @@ 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"
 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
@@ -1009,6 +1011,35 @@ 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(
+    committee: sql.Committee,
+    download_path_suffix: safe.RelPath | None,
+) -> str:
+    publish_url = config.get().SVN_PUBLISH_URL
+    if not publish_url:
+        raise ValueError("SVN_PUBLISH_URL is not configured")
+    relpath = paths.committee_downloads_dir(committee).path.relative_to(paths.get_downloads_dir().path)
+    if download_path_suffix is not None:
+        relpath = relpath / download_path_suffix.as_path()
+    return f"{publish_url.rstrip('/')}/{relpath}"
+
+
 async def task_archive_url(task_mid: str, recipient: str | None = None) -> str | None:
     try:
         return await task_archive_url_strict(task_mid, recipient)
diff --git a/tests/unit/test_svn_publish_helpers.py b/tests/unit/test_svn_publish_helpers.py
new file mode 100644
index 00000000..2cbd4f19
--- /dev/null
+++ b/tests/unit/test_svn_publish_helpers.py
@@ -0,0 +1,80 @@
+# 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 pytest
+
+import atr.config as config
+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.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
+
+
+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)
+
+
+def test_svn_publish_models_round_trip() -> None:
+    task_args = args.SvnPublish(
+        asf_uid="alice",
+        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())
+
+    assert str(task_args.project_key) == "apple"
+    assert task_args.download_path_suffix is None
+    assert isinstance(restored, results.SvnPublish)
+    assert restored.svn_revision == 12345
+    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")
+    committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+    target_url = util.svn_publish_target_url(committee, safe.RelPath("apple-1.0"))
+
+    assert target_url == "https://dist.apache.org/repos/dist/atr/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)
+
+    with pytest.raises(ValueError):
+        util.svn_publish_target_url(committee, None)
diff --git a/tests/unit/test_svn_publish_tasks_db.py b/tests/unit/test_svn_publish_tasks_db.py
new file mode 100644
index 00000000..19167a86
--- /dev/null
+++ b/tests/unit/test_svn_publish_tasks_db.py
@@ -0,0 +1,194 @@
+# 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
+from collections.abc import AsyncIterator
+from typing import Final
+
+import pytest
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.db as db
+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")
+
+
+@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_completed_publish_for_revision_returns_latest_valid_result(sqlite_sessionmaker) -> None:
+    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)
+        await data.commit()
+
+        result = await interaction.release_completed_svn_publish_task_for_revision(
+            PROJECT, VERSION, REVISION, caller_data=data
+        )
+
+        assert result is not None
+        assert result.task_args["target_url"] == TARGET_URL
+
+
+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),
+            )
+        )
+        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
+
+
+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
+        )
+
+        assert result is not None
+        assert result.status == sql.TaskStatus.FAILED
+
+
+def _publish_task(
+    *,
+    target_url: str,
+    status: sql.TaskStatus,
+    added: datetime.datetime,
+    revision_number: str = "00001",
+    project_key: str = "project",
+    version_key: str = "1.0.0",
+) -> sql.Task:
+    completed: datetime.datetime | None = None
+    error: str | None = None
+    pid: int | None = None
+    result: dict[str, object] | None = None
+    started: datetime.datetime | None = None
+    if status == sql.TaskStatus.ACTIVE:
+        started = added
+        pid = 1234
+    elif status == sql.TaskStatus.COMPLETED:
+        completed = added
+        result = {
+            "kind": "svn_publish",
+            "svn_revision": 42,
+            "target_url": target_url,
+            "message": "ok",
+        }
+    elif status == sql.TaskStatus.FAILED:
+        completed = added
+        error = "publish failed"
+    return sql.Task(
+        status=status,
+        task_type=sql.TaskType.SVN_PUBLISH,
+        task_args={
+            "asf_uid": "alice",
+            "project_key": project_key,
+            "version_key": version_key,
+            "revision_number": revision_number,
+            "download_path_suffix": None,
+            "target_url": target_url,
+        },
+        asf_uid="alice",
+        project_key=project_key,
+        version_key=version_key,
+        revision_number=revision_number,
+        added=added,
+        started=started,
+        pid=pid,
+        completed=completed,
+        result=result,
+        error=error,
+    )


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