You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/09/22 07:54:17 UTC

(tooling-trusted-releases) 01/01: Configure the dist watcher to supersede draft/candidate releases that match one seen in SVN

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

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

commit 03ee3bcc8c6a50106c6af7a6f9d3dea11c454cd3
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Sep 16 15:41:06 2026 +0100

    Configure the dist watcher to supersede draft/candidate releases that match one seen in SVN
---
 atr/construct.py                        |   8 +++
 atr/storage/__init__.py                 |   1 +
 atr/storage/writers/release.py          |  40 +++++++++++-
 atr/svn/catalog.py                      | 112 ++++++++++++++++++++++++++------
 tests/unit/test_release_notification.py |  18 ++++-
 5 files changed, 157 insertions(+), 22 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index d018f217..3e32d125 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -328,6 +328,7 @@ def release_notification(
     version: str,
     released: datetime.datetime,
     detected: bool = False,
+    superseded_draft: bool = False,
 ) -> args.Send:
     # A detected release is one the watcher found published in the dist area rather than
     # one ATR made itself, so it's named as such and the body says where it came from
@@ -340,6 +341,13 @@ def release_notification(
     else:
         subject = f"{committee.display_name} Released {project.short_display_name} {version}"
         provenance = ""
+    if superseded_draft:
+        # The watcher found this published outside ATR while ATR still held an unfinished
+        # draft or candidate for the same version, so it removed that draft - the email says so
+        provenance += (
+            "ATR held an in-progress draft or candidate for this version, "
+            "which has been removed in favour of the published release.\n\n"
+        )
     body = (
         f"{committee.display_name} has released {project.short_display_name} {version}.\n\n"
         f"{provenance}"
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 8eec75fe..2263447b 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -331,6 +331,7 @@ class WriteAsDistCatalogService(WriteAsSystemService):
         self.release_catalogue_release = admin.release.catalogue_release
         self.release_notify_archived = admin.release.notify_archived
         self.release_notify_seen = admin.release.notify_seen
+        self.release_supersede_draft = admin.release.supersede_draft
 
 
 class WriteAsKeysReflectionService(WriteAsSystemService):
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 7875d61f..901bada0 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -2111,6 +2111,7 @@ class FoundationAdmin(FoundationCommitter):
         project_key: safe.ProjectKey,
         version: safe.VersionKey,
         released: datetime.datetime,
+        superseded_draft: bool = False,
     ) -> str | None:
         """Tell the releases list about a release seen published in the dist area.
 
@@ -2123,7 +2124,9 @@ class FoundationAdmin(FoundationCommitter):
             return f"Project {project_key!s} not found"
         if project.committee is None:
             return f"Project {project_key!s} has no committee"
-        notification = construct.release_notification(project.committee, project, str(version), released, detected=True)
+        notification = construct.release_notification(
+            project.committee, project, str(version), released, detected=True, superseded_draft=superseded_draft
+        )
         self.__data.add(
             sql.Task(
                 status=sql.TaskStatus.QUEUED,
@@ -2137,6 +2140,41 @@ class FoundationAdmin(FoundationCommitter):
         await self.__data.commit()
         return None
 
+    async def supersede_draft(
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        author_asf_uid: str | None,
+    ) -> str | None:
+        """Discard an in-progress draft overtaken by an external dist publish.
+
+        ATR still held an unfinished draft of a version that has now shown up
+        published in the dist area, so the draft can't be finished. We remove it
+        with the usual teardown and, where we know who was working on it, tell
+        them - leaving the version clear for the published release to be
+        catalogued over the top.
+        """
+        error = await self.delete(project_key, version)
+        if error is not None:
+            return error
+        if author_asf_uid is not None:
+            message = (
+                f"Release {project_key!s} {version!s} was published to the distribution area outside ATR, "
+                "so your in-progress draft has been removed."
+            )
+            insert_stmt = sql.notification_insert(author_asf_uid, message, sql.NotificationLevel.WARNING)
+            await self.__data.execute(insert_stmt)
+            await self.__data.commit()
+        # The system did the wipe, so it's the actor; the draft's author is only
+        # the notification recipient, recorded as its own field
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+            version=str(version),
+            notified_asf_uid=author_asf_uid,
+        )
+        return None
+
     async def __delete_body(
         self,
         release: sql.Release,
diff --git a/atr/svn/catalog.py b/atr/svn/catalog.py
index a8de9fa1..0431359a 100644
--- a/atr/svn/catalog.py
+++ b/atr/svn/catalog.py
@@ -53,6 +53,22 @@ type _ResolvedArchive = tuple[safe.ProjectKey, safe.VersionKey]
 type _ReleaseKey = tuple[str, str | None, str]
 
 
+class _SupersededDraft(NamedTuple):
+    # An in-progress draft ATR still holds for a version now seen published outside
+    # ATR. The draft is wiped and, where we know who was working on it, its author
+    # told, before the published release is catalogued over the top
+    project_key: safe.ProjectKey
+    version_key: safe.VersionKey
+    author_asf_uid: str | None
+
+
+class _ReleaseResolution(NamedTuple):
+    # What resolving one added release comes to: the release to catalogue when it's
+    # new to ATR, and any draft it supersedes that has to be wiped first
+    catalogue: _ResolvedRelease | None
+    supersede: _SupersededDraft | None
+
+
 @dataclasses.dataclass
 class _ReleaseFiles:
     # Added files for one release in a commit as (dir-under-release, basename, classification),
@@ -97,28 +113,39 @@ async def catalogue_commit(commit: dict) -> None:
     # Resolve against ATR's projects in a read session first, so decomposition and
     # project lookups stay out of the storage writer
     async with db.session() as data:
-        releases, archives = await _resolve_changes(rules, data, added, removed, changes.removed_files)
+        releases, supersessions, archives = await _resolve_changes(rules, data, added, removed, changes.removed_files)
     # Report mode (the default) just logs the cataloguing it would do; active mode hands
     # the resolved work to the system actor. The releases list is told either way, since
     # that only depends on having seen the release, not on having recorded it
     async with storage.write_as_system(storage.WriteAsDistCatalogService) as wadcs:
         if config.get().DIST_CATALOG_WRITE:
-            seen, retired = await _apply_changes(wadcs, releases, archives, date)
+            seen, retired = await _apply_changes(wadcs, releases, supersessions, archives, date)
+            superseded_keys = {f"{s.project_key!s}-{s.version_key!s}" for s in supersessions}
         else:
-            _report(releases, archives, date)
+            _report(releases, supersessions, archives, date)
             seen, retired = releases, archives
-        await _notify(wadcs, seen, retired, date)
+            # Report mode wipes nothing, so its email never claims a draft was removed
+            superseded_keys = set()
+        await _notify(wadcs, seen, retired, date, superseded_keys)
 
 
 async def _apply_changes(
     wadcs: storage.WriteAsDistCatalogService,
     releases: list[_ResolvedRelease],
+    supersessions: list[_SupersededDraft],
     archives: list[_ResolvedArchive],
     date: datetime.datetime,
 ) -> tuple[list[_ResolvedRelease], list[_ResolvedArchive]]:
+    # An external publish overtook a draft ATR still held. Wipe the draft first, so
+    # cataloguing the published release below finds a clear version rather than the
+    # stale row, which catalogue_release would otherwise leave alone
+    superseded_failed = await _apply_supersessions(wadcs, supersessions)
     catalogued: list[_ResolvedRelease] = []
     retired: list[_ResolvedArchive] = []
     for project_key, version_key, artifacts in releases:
+        if f"{project_key!s}-{version_key!s}" in superseded_failed:
+            # The stale draft is still there, so cataloguing would only be skipped
+            continue
         try:
             error = await wadcs.release_catalogue_release(project_key, version_key, date, artifacts)
         except Exception:
@@ -141,6 +168,26 @@ async def _apply_changes(
     return catalogued, retired
 
 
+async def _apply_supersessions(
+    wadcs: storage.WriteAsDistCatalogService,
+    supersessions: list[_SupersededDraft],
+) -> set[str]:
+    # Wipe each draft an external publish overtook, returning the keys whose wipe failed so
+    # the caller skips cataloguing over a draft that's still present
+    failed: set[str] = set()
+    for project_key, version_key, author_asf_uid in supersessions:
+        try:
+            error = await wadcs.release_supersede_draft(project_key, version_key, author_asf_uid)
+        except Exception:
+            log.exception(f"dist watcher failed to supersede draft {project_key!s} {version_key!s}")
+            failed.add(f"{project_key!s}-{version_key!s}")
+            continue
+        if error is not None:
+            log.warning(f"dist watcher could not supersede draft {project_key!s} {version_key!s}: {error}")
+            failed.add(f"{project_key!s}-{version_key!s}")
+    return failed
+
+
 def _artifacts(rel_files: _ReleaseFiles) -> list[release.ArtifactInput]:
     # One input per real artifact file (source/binary/docs); companions are paired
     # from the same directory in this commit, and left null when they're not here
@@ -253,9 +300,11 @@ async def _notify(
     releases: list[_ResolvedRelease],
     archives: list[_ResolvedArchive],
     date: datetime.datetime,
+    superseded_keys: set[str],
 ) -> None:
     for project_key, version_key, _ in releases:
-        error = await wadcs.release_notify_seen(project_key, version_key, date)
+        superseded = f"{project_key!s}-{version_key!s}" in superseded_keys
+        error = await wadcs.release_notify_seen(project_key, version_key, date, superseded_draft=superseded)
         if error is not None:
             log.warning(f"dist watcher could not notify for {project_key!s} {version_key!s}: {error}")
     for project_key, version_key in archives:
@@ -266,9 +315,15 @@ async def _notify(
 
 def _report(
     releases: list[_ResolvedRelease],
+    supersessions: list[_SupersededDraft],
     archives: list[_ResolvedArchive],
     date: datetime.datetime,
 ) -> None:
+    for project_key, version_key, author_asf_uid in supersessions:
+        log.info(
+            f"dist watcher (report mode) would supersede in-progress draft {project_key!s} {version_key!s} "
+            f"(notifying {author_asf_uid or 'unknown author'}) before cataloguing the published release"
+        )
     for project_key, version_key, artifacts in releases:
         log.info(
             f"dist watcher (report mode) would catalogue release {project_key!s} {version_key!s} "
@@ -298,12 +353,15 @@ async def _resolve_changes(
     added: dict[_ReleaseKey, _ReleaseFiles],
     removed: set[_ReleaseKey],
     removed_files: list[tuple[str, str]],
-) -> tuple[list[_ResolvedRelease], list[_ResolvedArchive]]:
+) -> tuple[list[_ResolvedRelease], list[_SupersededDraft], list[_ResolvedArchive]]:
     releases: list[_ResolvedRelease] = []
+    supersessions: list[_SupersededDraft] = []
     for rel_files in sorted(added.values(), key=lambda r: (r.committee, r.subproject or "", r.version)):
-        resolved = await _resolve_release(rules, data, rel_files)
-        if resolved is not None:
-            releases.append(resolved)
+        resolution = await _resolve_release(rules, data, rel_files)
+        if resolution.catalogue is not None:
+            releases.append(resolution.catalogue)
+        if resolution.supersede is not None:
+            supersessions.append(resolution.supersede)
     archives: list[_ResolvedArchive] = []
     archived_keys: set[str] = set()
     for committee, subproject, version in sorted(removed, key=lambda k: (k[0], k[1] or "", k[2])):
@@ -325,7 +383,7 @@ async def _resolve_changes(
                 continue
             archives.append((project_key, version_key))
             archived_keys.add(release_key)
-    return releases, archives
+    return releases, supersessions, archives
 
 
 async def _resolve_project(
@@ -380,22 +438,38 @@ async def _published_release_keys(
     return keys
 
 
-async def _resolve_release(
-    rules: dist.DistRules, data: db.Session, rel_files: _ReleaseFiles
-) -> _ResolvedRelease | None:
+async def _draft_author(data: db.Session, release_record: sql.Release) -> str | None:
+    # Who last worked the draft, so the wipe can tell them. latest_revision_number is the
+    # bare revision number, not the composite key, so it's paired with the release key
+    number = release_record.latest_revision_number
+    if number is None:
+        return None
+    revision = await data.revision(release_key=release_record.key, number=number).get()
+    return revision.asfuid if revision is not None else None
+
+
+async def _resolve_release(rules: dist.DistRules, data: db.Session, rel_files: _ReleaseFiles) -> _ReleaseResolution:
     version = rel_files.version
     project = await _resolve_project(rules, data, rel_files.committee, rel_files.subproject)
     if project is None:
         log.info(f"dist commit for unknown project: {rel_files.committee}/{rel_files.subproject or ''} {version}")
-        return None
-    if await data.release(project_key=project.key, version=version).get() is not None:
-        # Already catalogued, whether by ATR or an earlier watcher pass
-        return None
+        return _ReleaseResolution(None, None)
     keys = _safe_keys(project.key, version)
     if keys is None:
-        return None
+        return _ReleaseResolution(None, None)
     project_key, version_key = keys
-    return project_key, version_key, _artifacts(rel_files)
+    existing = await data.release(project_key=project.key, version=version).get()
+    supersede: _SupersededDraft | None = None
+    if existing is not None:
+        if existing.phase == sql.ReleasePhase.RELEASE:
+            # Already catalogued as released, whether by ATR or an earlier watcher pass
+            return _ReleaseResolution(None, None)
+        # ATR still holds an in-progress draft of a version that has now been published
+        # outside ATR. The draft is stale, so flag it to be wiped and its author told,
+        # then catalogue the published release over the top
+        author = await _draft_author(data, existing)
+        supersede = _SupersededDraft(project_key, version_key, author)
+    return _ReleaseResolution((project_key, version_key, _artifacts(rel_files)), supersede)
 
 
 def _safe_keys(project_key: str, version: str) -> tuple[safe.ProjectKey, safe.VersionKey] | None:
diff --git a/tests/unit/test_release_notification.py b/tests/unit/test_release_notification.py
index 7213a004..9997445f 100644
--- a/tests/unit/test_release_notification.py
+++ b/tests/unit/test_release_notification.py
@@ -23,11 +23,13 @@ import atr.models.args as args
 import atr.models.sql as sql
 
 
-def _notification(detected: bool = False) -> args.Send:
+def _notification(detected: bool = False, superseded_draft: bool = False) -> args.Send:
     committee = sql.Committee(name="Apache Example", key="example")
     project = sql.Project(name="Apache Example", key="example")
     released = datetime.datetime(2026, 7, 3, 12, 0, 0, tzinfo=datetime.UTC)
-    return construct.release_notification(committee, project, "1.2.3", released, detected=detected)
+    return construct.release_notification(
+        committee, project, "1.2.3", released, detected=detected, superseded_draft=superseded_draft
+    )
 
 
 def test_release_notification_goes_to_the_releases_list_from_noreply():
@@ -68,3 +70,15 @@ def test_managed_release_not_detected_text():
     send = _notification()
 
     assert "detected" not in send.body.lower()
+
+
+def test_superseding_a_draft_states_the_draft_was_removed():
+    send = _notification(detected=True, superseded_draft=True)
+
+    assert "has been removed in favour of the published release" in send.body
+
+
+def test_a_release_that_supersedes_no_draft_does_not_mention_a_removed_draft():
+    send = _notification(detected=True)
+
+    assert "removed" not in send.body.lower()


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