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