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/29 18:25:07 UTC

(tooling-trusted-releases) branch main updated: Warn about and then delete inactive unfinished releases

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 6ad5c0fa Warn about and then delete inactive unfinished releases
6ad5c0fa is described below

commit 6ad5c0fa80973f9ba4d62f76077eb1d3c2f51cd3
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri May 29 19:08:36 2026 +0100

    Warn about and then delete inactive unfinished releases
---
 atr/constants.py                                  |   7 +
 atr/db/interaction.py                             |   4 +-
 atr/get/compose.py                                |  17 +
 atr/get/finish.py                                 |  16 +
 atr/models/sql.py                                 |   6 +
 atr/post/__init__.py                              |   2 +
 atr/post/release.py                               |  58 +++
 atr/server.py                                     |   9 +-
 atr/shared/activity.py                            |  32 ++
 atr/shared/start.py                               |   4 +-
 atr/storage/__init__.py                           |  13 +-
 atr/storage/writers/release.py                    | 389 ++++++++++++++++---
 atr/storage/writers/revision.py                   |   6 +-
 atr/storage/writers/vote.py                       |  13 +
 atr/tasks/inactivity.py                           | 388 +++++++++++++++++++
 atr/tasks/maintenance.py                          |  17 +
 atr/tasks/quarantine.py                           |   3 +-
 atr/templates/check-selected.html                 |   3 +
 atr/user.py                                       |  12 +
 atr/worker.py                                     |   5 +-
 migrations/versions/0090_2026.05.29_a13b8c92.py   |  96 +++++
 tests/unit/test_archived_project_writer_guards.py | 305 +++++++++++++++
 tests/unit/test_create_revision.py                |   6 +
 tests/unit/test_inactivity.py                     | 443 ++++++++++++++++++++++
 tests/unit/test_post_release.py                   |  83 ++++
 25 files changed, 1873 insertions(+), 64 deletions(-)

diff --git a/atr/constants.py b/atr/constants.py
index d24ac8ba..50780127 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -235,3 +235,10 @@ GENERATED_FILE_SUFFIXES: Final[tuple[str, ...]] = (
     ".min.js",
     ".min.map",
 )
+
+INACTIVITY_DELETE_DAYS: Final[int] = 90
+INACTIVITY_WARNING_DAYS: Final[int] = 80
+
+# Fixed sub claim on JWTs minted from system PATs, and the UID we log for
+# their actions. The minting admin is recorded separately in PAT.created_by.
+SYSTEM_SERVICE_UID: Final[str] = "system"
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 0350af15..52a74f44 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -39,6 +39,7 @@ import atr.models.github as github
 import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.principal as principal
 import atr.user as user
 import atr.util as util
 import atr.web as web
@@ -679,7 +680,8 @@ async def release_ready_to_start_vote(
     if await pending_quarantine_count(release.key, caller_data=data) > 0:
         return PENDING_QUARANTINE_VOTE_BLOCK_MESSAGE
 
-    if not (user.is_committee_member(committee, session.uid) or session.is_admin):
+    authorisation = await principal.Authorisation(session)
+    if not (authorisation.is_member_of(committee.key) or session.is_admin):
         return "You must be on the PMC of this project to start a vote"
 
     has_files = await util.has_files(release)
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 2f8fdf3d..4dac26f0 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -39,9 +39,11 @@ import atr.models.sql as sql
 import atr.paths as paths
 import atr.post as post
 import atr.render as render
+import atr.shared.activity as activity
 import atr.shared.draft as draft
 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
 
@@ -157,6 +159,7 @@ async def selected(
         submit_classes="btn btn-primary",
         submit_disabled=release.check_cache_key is None,
     )
+    activity_form_html = await _activity_form_html(release, session)
 
     has_files = await util.has_files(release)
 
@@ -234,6 +237,7 @@ async def selected(
         models=sql,
         recheck_form=recheck_form,
         cache_reset_form=cache_reset_form,
+        activity_form=activity_form_html,
         csrf_input=str(form.csrf_input()),
         has_files=has_files,
         blocker_errors=blocker_errors,
@@ -262,6 +266,19 @@ async def status_selected(
         return _status_error_response(None, default_status=500, expose_message=False)
 
 
+async def _activity_form_html(release: sql.Release, session: web.Committer) -> str:
+    if not user.is_participant_for_committee(release.committee, session.projects):
+        return ""
+    activity_form = await form.render(
+        model_cls=form.Empty,
+        action=util.as_url(post.release.activity, project_key=release.project.key, version_key=release.version),
+        submit_label="Reset inactivity clock",
+        submit_classes="btn-outline-primary",
+        pre_submit=activity.inactivity_form_intro(release),
+    )
+    return str(activity_form)
+
+
 def _banner_html(quarantine_pending: int, ongoing: int) -> str:
     is_are = "is" if (ongoing == 1) else "are"
     task_word = "task" if (ongoing == 1) else "tasks"
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 8348c065..1e653c67 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -45,9 +45,12 @@ import atr.models.args as args
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as paths
+import atr.post as post
 import atr.render as render
 import atr.shared as shared
+import atr.shared.activity as activity
 import atr.template as template
+import atr.user as user
 import atr.util as util
 import atr.web as web
 
@@ -103,6 +106,7 @@ async def selected(
             }"
 
     return await _render_page(
+        session=session,
         release=release,
         deletable_dirs=deletable_dirs,
         rc_analysis=rc_analysis,
@@ -288,6 +292,7 @@ def _render_distribution_tasks(release: sql.Release, tasks: Sequence[sql.Task])
 
 
 async def _render_page(
+    session: web.Committer,
     release: sql.Release,
     deletable_dirs: list[tuple[str, str]],
     rc_analysis: RCTagAnalysisResult,
@@ -358,6 +363,17 @@ async def _render_page(
     # Remove RC tags section
     page.append(await _render_rc_tags_section(rc_analysis))
 
+    if user.is_participant_for_committee(release.committee, session.projects):
+        page.h2["Inactivity"]
+        activity_form = await form.render(
+            model_cls=form.Empty,
+            action=util.as_url(post.release.activity, project_key=release.project.key, version_key=release.version),
+            submit_label="Reset inactivity clock",
+            submit_classes="btn-outline-primary",
+            pre_submit=activity.inactivity_form_intro(release, action="flagged"),
+        )
+        page.div(".mb-4")[activity_form]
+
     # Custom styles
     page_styles = """
         .page-file-select-text {
diff --git a/atr/models/sql.py b/atr/models/sql.py
index eeba5404..c95add4b 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1236,6 +1236,12 @@ class Release(sqlmodel.SQLModel, table=True):
         sa_column=sqlalchemy.Column(UTCDateTime),
         **example(datetime.datetime(2026, 1, 15, 1, 2, 3, tzinfo=datetime.UTC)),
     )
+    activity_at: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
+    )
+    inactivity_notice_key: str | None = sqlmodel.Field(default=None)
     # Set at start time when the user opts in to archiving the prior release
     # in this cycle when this release is announced.
     archive_prior_release: bool = sqlmodel.Field(default=False)
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index c76d4f61..478d3356 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -26,6 +26,7 @@ import atr.post.keys as keys
 import atr.post.manual as manual
 import atr.post.notifications as notifications
 import atr.post.projects as projects
+import atr.post.release as release
 import atr.post.resolve as resolve
 import atr.post.revisions as revisions
 import atr.post.sbom as sbom
@@ -49,6 +50,7 @@ __all__ = [
     "manual",
     "notifications",
     "projects",
+    "release",
     "resolve",
     "revisions",
     "sbom",
diff --git a/atr/post/release.py b/atr/post/release.py
new file mode 100644
index 00000000..89bbd3d3
--- /dev/null
+++ b/atr/post/release.py
@@ -0,0 +1,58 @@
+# 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.
+
+from typing import Literal
+
+import quart
+
+import atr.blueprints.post as post
+import atr.form as form
+import atr.get as get
+import atr.mapping as mapping
+import atr.models.safe as safe
+import atr.storage as storage
+import atr.web as web
+
+
+@post.typed
+async def activity(
+    session: web.Committer,
+    _release: Literal["release"],
+    _activity: Literal["activity"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    _form: form.Empty,
+) -> web.WerkzeugResponse:
+    """
+    URL: /release/activity/<project_key>/<version_key>
+    """
+    return await _bump_activity(session, project_key, version_key)
+
+
+async def _bump_activity(
+    session: web.Committer,
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+) -> web.WerkzeugResponse:
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_key)
+            release = await wacp.release.bump_activity(project_key, version_key)
+    except storage.AccessError as e:
+        return await session.redirect(get.root.index, error=str(e))
+    await quart.flash("Inactivity clock reset", "success")
+    return await mapping.release_as_redirect(session, release)
diff --git a/atr/server.py b/atr/server.py
index b0cd2e47..ce55436a 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -51,6 +51,7 @@ import atr
 import atr.blueprints as blueprints
 import atr.cache as cache
 import atr.config as config
+import atr.constants as constants
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.errors as errors
@@ -992,19 +993,19 @@ async def _register_recurrent_tasks() -> None:
     """Schedule recurring tasks"""
     await tasks.clear_scheduled()
     # Run maintenance task immediately on server startup
-    maintenance = await tasks.run_maintenance(asf_uid="system", schedule_next=True)
+    maintenance = await tasks.run_maintenance(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
     log.info(f"Scheduled maintenance with ID {maintenance.id}")
     # Start other tasks 5 min after server start
     await asyncio.sleep(300)
     try:
         await asyncio.sleep(60)
-        metadata = await tasks.metadata_update(asf_uid="system", schedule_next=True)
+        metadata = await tasks.metadata_update(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
         log.info(f"Scheduled remote metadata update with ID {metadata.id}")
         await asyncio.sleep(60)
-        workflow = await tasks.workflow_update(asf_uid="system", schedule_next=True)
+        workflow = await tasks.workflow_update(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
         log.info(f"Scheduled workflow status update with ID {workflow.id}")
         await asyncio.sleep(60)
-        dist_check = await tasks.distribution_status_check(asf_uid="system", schedule_next=True)
+        dist_check = await tasks.distribution_status_check(asf_uid=constants.SYSTEM_SERVICE_UID, schedule_next=True)
         log.info(f"Scheduled distribution status update with ID {dist_check.id}")
 
     except Exception as e:
diff --git a/atr/shared/activity.py b/atr/shared/activity.py
new file mode 100644
index 00000000..de6c6a53
--- /dev/null
+++ b/atr/shared/activity.py
@@ -0,0 +1,32 @@
+# 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 atr.htm as htm
+import atr.models.sql as sql
+import atr.util as util
+
+
+def inactivity_form_intro(release: sql.Release, action: str = "deleted") -> htm.Element:
+    days = max(0, (datetime.datetime.now(datetime.UTC) - release.activity_at).days)
+    return htm.div[
+        htm.div(".mb-2")[
+            f"This release has been inactive for {util.plural(days, 'day')}. "
+            f"After 90 days of inactivity this project will be {action}."
+        ],
+    ]
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 88799660..d72bea7f 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -27,7 +27,9 @@ class StartReleaseForm(form.Form):
         "Enter the version string for this new release."
         " This cannot be changed later, and must be the version of the finished release."
         " ATR generates a unique revision serial number for each voting round,"
-        " and you can also set your own tag before a vote starts.",
+        " and you can also set your own tag before a vote starts."
+        " Release candidate drafts inactive for 90 days are cleaned up automatically,"
+        " with a warning email sent at 80 days.",
     )
     auto_archive_prior: form.Bool = form.label(
         "Auto archive prior release",
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 69ad9a37..c67731e7 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -21,11 +21,13 @@ import contextlib
 import datetime
 import json
 import logging
-from typing import TYPE_CHECKING, Final
+import types
+from typing import TYPE_CHECKING, Any, Final
 
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator
 
+import atr.constants as constants
 import atr.db as db
 import atr.log as log
 import atr.models.basic as basic
@@ -594,6 +596,15 @@ async def write_as_project_release_manager(
         yield await w.as_project_release_manager(project_key)
 
 
+@contextlib.asynccontextmanager
+async def write_as_system() -> AsyncGenerator[writers.release.FoundationAdmin]:
+    async with db.session() as data:
+        system_authorisation = types.SimpleNamespace(asf_uid=constants.SYSTEM_SERVICE_UID)
+        system_write: Any = types.SimpleNamespace(authorisation=system_authorisation)
+        system_write_as: Any = WriteAs()
+        yield writers.release.FoundationAdmin(system_write, system_write_as, data)
+
+
 @contextlib.asynccontextmanager
 async def write_as_user_service(asf_uid: str) -> AsyncGenerator[WriteAsUserService]:
     async with db.session() as data:
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 3d6e6bc9..978dd9e3 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -33,6 +33,7 @@ import sqlmodel
 
 import atr.analysis as analysis
 import atr.config as config
+import atr.constants as constants
 import atr.cycles as cycles
 import atr.db as db
 import atr.db.interaction as interaction
@@ -60,6 +61,13 @@ if TYPE_CHECKING:
 
 SPECIAL_SUFFIXES: Final[frozenset[str]] = frozenset({".asc", ".sha256", ".sha512"})
 
+_ACTIVITY_BUMP_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+    {
+        sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        sql.ReleasePhase.RELEASE_CANDIDATE,
+        sql.ReleasePhase.RELEASE_PREVIEW,
+    }
+)
 _SIGNATURE_CHECKER_KEY: Final[str] = checks.function_key(signature.check)
 
 
@@ -160,15 +168,55 @@ class CommitteeParticipant(FoundationCommitter):
         committee_key: str,
     ) -> None:
         super().__init__(write, write_as, data)
-        self.__write = write
-        self.__write_as = write_as
-        self.__data = data
         asf_uid = write.authorisation.asf_uid
         if asf_uid is None:
             raise storage.AccessError("Not authorized", status=403)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
         self.__asf_uid = asf_uid
         self.__committee_key = committee_key
 
+    async def bump_activity(self, project_key: safe.ProjectKey, version_key: safe.VersionKey) -> sql.Release:
+        release = await self.__data.release(
+            project_key=str(project_key), version=str(version_key), _committee=True
+        ).demand(storage.AccessError(f"Release '{project_key!s} {version_key!s}' not found.", status=404))
+        storage.ensure_project_active(release.project)
+        ineligible_phase = release.phase not in _ACTIVITY_BUMP_PHASES
+        if ineligible_phase:
+            raise storage.AccessError(
+                f"Release phase {release.phase.value} is not eligible for activity reset", status=409
+            )
+        now = datetime.datetime.now(datetime.UTC)
+        previous_activity_at = release.activity_at
+        via = sql.validate_instrumented_attribute
+        release_activity_at = via(sql.Release.activity_at)
+        activity_at_value = sqlalchemy.case(
+            (release_activity_at > now, release_activity_at),
+            else_=now,
+        )
+        result = await self.__data.execute(
+            sqlmodel.update(sql.Release)
+            .where(
+                via(sql.Release.key) == release.key,
+                via(sql.Release.phase).in_(_ACTIVITY_BUMP_PHASES),
+            )
+            .values(activity_at=activity_at_value, inactivity_notice_key=None)
+        )
+        if getattr(result, "rowcount", 0) != 1:
+            await self.__data.rollback()
+            raise storage.AccessError("The release state has changed, please refresh and try again", status=409)
+        await self.__data.refresh(release)
+        await self.__data.commit()
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+            version=str(version_key),
+            previous_activity_at=previous_activity_at.isoformat(),
+            activity_at=release.activity_at.isoformat(),
+        )
+        return release
+
     async def delete(
         self,
         project_key: safe.ProjectKey,
@@ -181,6 +229,15 @@ class CommitteeParticipant(FoundationCommitter):
             project_key=str(project_key), version=str(version), phase=phase, _committee=True
         ).demand(storage.AccessError(f"Release '{project_key!s} {version!s}' not found.", status=404))
         storage.ensure_project_active(release.project)
+        return await self.__delete_body(release, project_key, version, include_downloads)
+
+    async def __delete_body(
+        self,
+        release: sql.Release,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        include_downloads: bool,
+    ) -> str | None:
         release_dirs = [
             paths.release_directory_base(release),
             paths.get_attestable_dir() / str(project_key) / str(version),
@@ -247,6 +304,53 @@ class CommitteeParticipant(FoundationCommitter):
         )
         return error
 
+    async def __delete_release_data_downloads(self, release: sql.Release) -> None:
+        finished_dir = paths.release_directory(release)
+        if await aiofiles.os.path.isdir(finished_dir):
+            release_inodes = set()
+            async for file_path in util.paths_recursive(finished_dir):
+                try:
+                    stat_result = await aiofiles.os.stat(finished_dir / file_path)
+                    release_inodes.add(stat_result.st_ino)
+                except FileNotFoundError:
+                    continue
+
+            if release_inodes:
+                downloads_dir = paths.get_downloads_dir()
+                async for link_path in util.paths_recursive(downloads_dir):
+                    full_link_path = downloads_dir / link_path
+                    try:
+                        link_stat = await aiofiles.os.stat(full_link_path)
+                        if link_stat.st_ino in release_inodes:
+                            await aiofiles.os.remove(full_link_path)
+                            log.info(f"Deleted hard link: {full_link_path}")
+                    except FileNotFoundError:
+                        continue
+
+    async def __delete_release_data_filesystem(
+        self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
+    ) -> str | None:
+        delete_errors: list[str] = []
+        for release_dir in release_dirs:
+            if await aiofiles.os.path.isdir(release_dir):
+                try:
+                    log.info(f"Deleting filesystem directory: {release_dir}")
+                    await util.delete_immutable_directory(
+                        release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
+                    )
+                    log.info(f"Successfully deleted directory: {release_dir}")
+                except Exception as e:
+                    log.exception(f"Error deleting filesystem directory {release_dir}:")
+                    delete_errors.append(f"{release_dir}: {e!s}")
+            else:
+                log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
+        if delete_errors:
+            return (
+                f"Database records for '{project_key!s} {version!s}' deleted,"
+                f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
+            )
+        return None
+
     async def delete_empty_directory(
         self, project_key: safe.ProjectKey, version_key: safe.VersionKey, dir_to_delete_rel: pathlib.Path
     ) -> str | None:
@@ -703,10 +807,22 @@ class CommitteeParticipant(FoundationCommitter):
         # Promote it to RELEASE_CANDIDATE
         via = sql.validate_instrumented_attribute
         vote_seq = await self.__vote_seq_allocate(release_for_pre_checks.key)
+        now = datetime.datetime.now(datetime.UTC)
+        release_activity_at = via(sql.Release.activity_at)
+        activity_at_value = sqlalchemy.case(
+            (release_activity_at > now, release_activity_at),
+            else_=now,
+        )
+        notice_key_value = sqlalchemy.case(
+            (release_activity_at > now, via(sql.Release.inactivity_notice_key)),
+            else_=None,
+        )
         values: dict[str, object] = {
-            "vote_started": datetime.datetime.now(datetime.UTC),
+            "vote_started": now,
             "vote_resolved": None,
             "current_vote_seq": vote_seq,
+            "activity_at": activity_at_value,
+            "inactivity_notice_key": notice_key_value,
         }
         if promote:
             values["phase"] = sql.ReleasePhase.RELEASE_CANDIDATE
@@ -794,6 +910,7 @@ class CommitteeParticipant(FoundationCommitter):
         self.__validate_version(project, version)
         cycle_key = await self.__ensure_project_cycle(project, version)
 
+        now = datetime.datetime.now(datetime.UTC)
         release = sql.Release(
             phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
             project_key=project.key,
@@ -801,7 +918,8 @@ class CommitteeParticipant(FoundationCommitter):
             version=str(version),
             cycle_key=cycle_key,
             archive_prior_release=auto_archive,
-            created=datetime.datetime.now(datetime.UTC),
+            created=now,
+            activity_at=now,
         )
         self.__data.add(release)
         await self.__data.commit()
@@ -955,54 +1073,6 @@ class CommitteeParticipant(FoundationCommitter):
         all_current_paths_interim.sort(key=lambda p: (-len(p.parts), str(p)))
         return all_current_paths_interim
 
-    async def __delete_release_data_downloads(self, release: sql.Release) -> None:
-        # Delete hard links from the downloads directory
-        finished_dir = paths.release_directory(release)
-        if await aiofiles.os.path.isdir(finished_dir):
-            release_inodes = set()
-            async for file_path in util.paths_recursive(finished_dir):
-                try:
-                    stat_result = await aiofiles.os.stat(finished_dir / file_path)
-                    release_inodes.add(stat_result.st_ino)
-                except FileNotFoundError:
-                    continue
-
-            if release_inodes:
-                downloads_dir = paths.get_downloads_dir()
-                async for link_path in util.paths_recursive(downloads_dir):
-                    full_link_path = downloads_dir / link_path
-                    try:
-                        link_stat = await aiofiles.os.stat(full_link_path)
-                        if link_stat.st_ino in release_inodes:
-                            await aiofiles.os.remove(full_link_path)
-                            log.info(f"Deleted hard link: {full_link_path}")
-                    except FileNotFoundError:
-                        continue
-
-    async def __delete_release_data_filesystem(
-        self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
-    ) -> str | None:
-        delete_errors: list[str] = []
-        for release_dir in release_dirs:
-            if await aiofiles.os.path.isdir(release_dir):
-                try:
-                    log.info(f"Deleting filesystem directory: {release_dir}")
-                    await util.delete_immutable_directory(
-                        release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
-                    )
-                    log.info(f"Successfully deleted directory: {release_dir}")
-                except Exception as e:
-                    log.exception(f"Error deleting filesystem directory {release_dir}:")
-                    delete_errors.append(f"{release_dir}: {e!s}")
-            else:
-                log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
-        if delete_errors:
-            return (
-                f"Database records for '{project_key!s} {version!s}' deleted,"
-                f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
-            )
-        return None
-
     @contextlib.asynccontextmanager
     async def __open_for_replace(self, target_path: pathlib.Path) -> AsyncIterator[Any]:
         # Unlink first - the target could be a hardlink from a prior revision, locked 0o444.
@@ -1301,6 +1371,19 @@ class CommitteeMember(CommitteeParticipant):
 
 
 class FoundationAdmin(FoundationCommitter):
+    BLOCKING_QUARANTINE_STATUSES: Final[frozenset[sql.QuarantineStatus]] = frozenset(
+        {sql.QuarantineStatus.STAGING, sql.QuarantineStatus.PENDING}
+    )
+
+    ELIGIBLE_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+        {
+            sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+            sql.ReleasePhase.RELEASE_CANDIDATE,
+        }
+    )
+
+    BLOCKING_TASK_STATUSES: Final[frozenset[sql.TaskStatus]] = frozenset({sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE})
+
     def __init__(self, write: storage.Write, write_as: storage.WriteAsFoundationAdmin, data: db.Session) -> None:
         super().__init__(write, write_as, data)
         self.__write = write
@@ -1310,3 +1393,203 @@ class FoundationAdmin(FoundationCommitter):
         if asf_uid is None:
             raise storage.AccessError("Not authorized", status=403)
         self.__asf_uid = asf_uid
+
+    async def delete_inactive(
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        dry_run: bool = False,
+    ) -> str | None:
+        error = await self.__check_eligibility(project_key, version)
+        if error is not None:
+            return error
+        if dry_run:
+            return None
+        await self.__data.begin_immediate()
+        self.__data.expire_all()
+        final_error = await self.__check_eligibility(project_key, version)
+        if final_error is not None:
+            await self.__data.rollback()
+            return final_error
+        return await self.__delete(project_key, version)
+
+    async def __delete(
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        include_downloads: bool = True,
+    ) -> str | None:
+        release = await self.__data.release(project_key=str(project_key), version=str(version), _committee=True).demand(
+            storage.AccessError(f"Release '{project_key!s} {version!s}' not found.", status=404)
+        )
+        storage.ensure_project_active(release.project)
+        return await self.__delete_body(release, project_key, version, include_downloads)
+
+    async def __delete_body(
+        self,
+        release: sql.Release,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+        include_downloads: bool,
+    ) -> str | None:
+        release_dirs = [
+            paths.release_directory_base(release),
+            paths.get_attestable_dir() / str(project_key) / str(version),
+            paths.get_archives_dir() / str(project_key) / str(version),
+            paths.get_quarantined_dir() / str(project_key) / str(version),
+        ]
+
+        # Delete from the database using bulk SQL DELETE for efficiency
+        log.info(f"Deleting database records for release: {project_key!s} {version!s}")
+
+        # Bulk delete tasks
+        # These is no cascade, so we must delete explicitly
+        via = sql.validate_instrumented_attribute
+        task_delete_stmt = sqlmodel.delete(sql.Task).where(
+            via(sql.Task.project_key) == release.project.key,
+            via(sql.Task.version_key) == release.version,
+        )
+        task_result = await self.__data.execute(task_delete_stmt)
+        task_count = task_result.rowcount if isinstance(task_result, engine.CursorResult) else 0
+        log.debug(f"Deleted {util.plural(task_count, 'task')} for {project_key!s} {version!s}")
+
+        release_key = release.key
+
+        # These deletes would also be performed by database cascade
+        # We do them here before the commit instead to be explicit
+        rfs_delete_stmt = sqlmodel.delete(sql.ReleaseFileState).where(
+            via(sql.ReleaseFileState.release_key) == release_key,
+        )
+        rfs_result = await self.__data.execute(rfs_delete_stmt)
+        rfs_count = rfs_result.rowcount if isinstance(rfs_result, engine.CursorResult) else 0
+        log.debug(f"Deleted {util.plural(rfs_count, 'file state row')} for {project_key!s} {version!s}")
+
+        await self.__data.delete(release)
+        log.info(f"Deleted release record: {project_key!s} {version!s}")
+
+        # In test mode, delete the counter for test committee releases
+        # This allows revision numbers to be reused in testing
+        committee = release.project.committee
+        is_test_release = config.is_test_mode() and (committee is not None) and (committee.key == "test")
+        if is_test_release:
+            counter_delete_stmt = sqlmodel.delete(sql.RevisionCounter).where(
+                via(sql.RevisionCounter.release_key) == release_key
+            )
+            await self.__data.execute(counter_delete_stmt)
+            vote_counter_delete_stmt = sqlmodel.delete(sql.VoteCounter).where(
+                via(sql.VoteCounter.release_key) == release_key
+            )
+            await self.__data.execute(vote_counter_delete_stmt)
+            log.info(f"Deleted revision and vote counters for test release: {release_key}")
+
+        # Filesystem deletions are more likely to have permission errors than database deletions
+        # Therefore we do filesystem deletions first
+        if include_downloads:
+            await self.__delete_release_data_downloads(release)
+        error = await self.__delete_release_data_filesystem(release_dirs, project_key, version)
+
+        await self.__data.commit()
+
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+            version=str(version),
+            error=error,
+        )
+        return error
+
+    async def __delete_release_data_downloads(self, release: sql.Release) -> None:
+        finished_dir = paths.release_directory(release)
+        if await aiofiles.os.path.isdir(finished_dir):
+            release_inodes = set()
+            async for file_path in util.paths_recursive(finished_dir):
+                try:
+                    stat_result = await aiofiles.os.stat(finished_dir / file_path)
+                    release_inodes.add(stat_result.st_ino)
+                except FileNotFoundError:
+                    continue
+
+            if release_inodes:
+                downloads_dir = paths.get_downloads_dir()
+                async for link_path in util.paths_recursive(downloads_dir):
+                    full_link_path = downloads_dir / link_path
+                    try:
+                        link_stat = await aiofiles.os.stat(full_link_path)
+                        if link_stat.st_ino in release_inodes:
+                            await aiofiles.os.remove(full_link_path)
+                            log.info(f"Deleted hard link: {full_link_path}")
+                    except FileNotFoundError:
+                        continue
+
+    async def __delete_release_data_filesystem(
+        self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
+    ) -> str | None:
+        delete_errors: list[str] = []
+        for release_dir in release_dirs:
+            if await aiofiles.os.path.isdir(release_dir):
+                try:
+                    log.info(f"Deleting filesystem directory: {release_dir}")
+                    await util.delete_immutable_directory(
+                        release_dir, reason=f"user {self.__asf_uid} is deleting release {project_key!s} {version!s}"
+                    )
+                    log.info(f"Successfully deleted directory: {release_dir}")
+                except Exception as e:
+                    log.exception(f"Error deleting filesystem directory {release_dir}:")
+                    delete_errors.append(f"{release_dir}: {e!s}")
+            else:
+                log.warning(f"Filesystem directory not found, skipping deletion: {release_dir}")
+        if delete_errors:
+            return (
+                f"Database records for '{project_key!s} {version!s}' deleted,"
+                f" but failed to delete filesystem directories: {', '.join(delete_errors)}"
+            )
+        return None
+
+    async def __check_eligibility(
+        self,
+        project_key: safe.ProjectKey,
+        version: safe.VersionKey,
+    ) -> str | None:
+        release = await self.__data.release(project_key=str(project_key), version=str(version), _committee=True).get()
+        if release is None:
+            return "release no longer exists"
+        if release.project.status != sql.ProjectStatus.ACTIVE:
+            return f"release project '{release.project_key}' is not active"
+        if release.phase not in FoundationAdmin.ELIGIBLE_PHASES:
+            return f"release phase {release.phase.value} is not eligible for system deletion"
+        now = datetime.datetime.now(datetime.UTC)
+        age_days = (now - release.activity_at).days
+        if age_days < constants.INACTIVITY_DELETE_DAYS:
+            return f"release activity is only {age_days} days old; threshold is {constants.INACTIVITY_DELETE_DAYS}"
+        if await self.__has_blocking_tasks(release):
+            return "release has queued or active tasks"
+        if await self.__has_blocking_quarantine(release):
+            return "release has staging or pending quarantine rows"
+        return None
+
+    async def __has_blocking_quarantine(self, release: sql.Release) -> bool:
+        via = sql.validate_instrumented_attribute
+        stmt = (
+            sqlmodel.select(sqlalchemy.func.count())
+            .select_from(sql.Quarantined)
+            .where(
+                via(sql.Quarantined.release_key) == release.key,
+                via(sql.Quarantined.status).in_(list(FoundationAdmin.BLOCKING_QUARANTINE_STATUSES)),
+            )
+        )
+        result = await self.__data.execute(stmt)
+        return int(result.scalar_one()) > 0
+
+    async def __has_blocking_tasks(self, release: sql.Release) -> bool:
+        via = sql.validate_instrumented_attribute
+        stmt = (
+            sqlmodel.select(sqlalchemy.func.count())
+            .select_from(sql.Task)
+            .where(
+                via(sql.Task.project_key) == release.project_key,
+                via(sql.Task.version_key) == release.version,
+                via(sql.Task.status).in_(list(FoundationAdmin.BLOCKING_TASK_STATUSES)),
+            )
+        )
+        result = await self.__data.execute(stmt)
+        return int(result.scalar_one()) > 0
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 5be7185a..4f1858c1 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -155,17 +155,21 @@ async def _commit_new_revision(
         # That makes models.populate_revision_sequence_and_name safe against races
         # Because that event is called when data.add is called below
         # And we have a write lock at that point through the use of data.begin_immediate
+        now = datetime.datetime.now(datetime.UTC)
         new_revision = sql.Revision(
             release_key=release_key,
             release=release,
             asfuid=asf_uid,
-            created=datetime.datetime.now(datetime.UTC),
+            created=now,
             phase=release.phase,
             description=description,
             merge_base_revision_key=merge_base_revision_key,
             was_quarantined=was_quarantined,
         )
         data.add(new_revision)
+        if release.activity_at <= now:
+            release.activity_at = now
+            release.inactivity_notice_key = None
 
         # Flush but do not commit the new revision row to get its name and number
         # The row will still be invisible to other sessions after flushing
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index b13dbc07..7d2dd0ba 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 import datetime
 from typing import Literal
 
+import sqlalchemy
 import sqlmodel
 
 import atr.config as config
@@ -1210,12 +1211,24 @@ class ReleaseManager(CommitteeParticipant):
             stmt = stmt.where(via(sql.Release.podling_thread_id).is_(None))
         else:
             stmt = stmt.where(via(sql.Release.podling_thread_id) == expected_podling_thread_id)
+        now = datetime.datetime.now(datetime.UTC)
+        release_activity_at = via(sql.Release.activity_at)
+        activity_at_value = sqlalchemy.case(
+            (release_activity_at > now, release_activity_at),
+            else_=now,
+        )
+        notice_key_value = sqlalchemy.case(
+            (release_activity_at > now, via(sql.Release.inactivity_notice_key)),
+            else_=None,
+        )
         result = await self.__data.execute(
             stmt.values(
                 phase=new_phase,
                 vote_mode=new_vote_mode,
                 vote_resolved=new_vote_resolved,
                 podling_thread_id=new_podling_thread_id,
+                activity_at=activity_at_value,
+                inactivity_notice_key=notice_key_value,
             )
         )
         if getattr(result, "rowcount", 0) != 1:
diff --git a/atr/tasks/inactivity.py b/atr/tasks/inactivity.py
new file mode 100644
index 00000000..bd428813
--- /dev/null
+++ b/atr/tasks/inactivity.py
@@ -0,0 +1,388 @@
+# 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.
+
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import enum
+from typing import TYPE_CHECKING, Final
+
+import sqlmodel
+
+import atr.config as config
+import atr.constants as constants
+import atr.db as db
+import atr.log as log
+import atr.mail as mail
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
+NOTICE_KIND_PREVIEW_ESCALATION: Final[str] = "preview_escalation"
+NOTICE_KIND_WARNING: Final[str] = "warning"
+
+_DELETION_ENABLED: Final[bool] = True
+_UNFINISHED_PHASES: Final[frozenset[sql.ReleasePhase]] = frozenset(
+    {
+        sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        sql.ReleasePhase.RELEASE_CANDIDATE,
+        sql.ReleasePhase.RELEASE_PREVIEW,
+    }
+)
+
+
+class Decision(enum.StrEnum):
+    DELETE_CANDIDATE = "delete_candidate"
+    PREVIEW_ESCALATE = "preview_escalate"
+    SKIP = "skip"
+    WARN = "warn"
+
+
+@dataclasses.dataclass(frozen=True)
+class Plan:
+    release_key: str
+    project_key: str
+    version: str
+    phase: sql.ReleasePhase
+    activity_at: datetime.datetime
+    decision: Decision
+
+
+async def apply_plan(plan: Plan) -> None:
+    match plan.decision:
+        case Decision.SKIP:
+            return
+        case Decision.WARN:
+            await _send_warning(plan)
+        case Decision.PREVIEW_ESCALATE:
+            await _send_preview_escalation(plan)
+        case Decision.DELETE_CANDIDATE:
+            await _delete_or_dry_run(plan)
+
+
+def classify(
+    release: sql.Release,
+    *,
+    now: datetime.datetime,
+) -> Plan:
+    activity_at = release.activity_at
+    age_days = (now - activity_at).days
+
+    if release.phase not in _UNFINISHED_PHASES:
+        return _plan(release, activity_at, Decision.SKIP)
+
+    if age_days < constants.INACTIVITY_WARNING_DAYS:
+        return _plan(release, activity_at, Decision.SKIP)
+
+    if age_days < constants.INACTIVITY_DELETE_DAYS:
+        return _plan(release, activity_at, Decision.WARN)
+
+    if release.phase == sql.ReleasePhase.RELEASE_PREVIEW:
+        return _plan(release, activity_at, Decision.PREVIEW_ESCALATE)
+    return _plan(release, activity_at, Decision.DELETE_CANDIDATE)
+
+
+def deletion_enabled() -> bool:
+    return _DELETION_ENABLED
+
+
+def private_committee_list(committee_key: str) -> str:
+    return f"private@{committee_key}.apache.org"
+
+
+async def run_scan() -> Sequence[Plan]:
+    now = datetime.datetime.now(datetime.UTC)
+    plans: list[Plan] = []
+    via = sql.validate_instrumented_attribute
+
+    async with db.session() as data:
+        query = (
+            sqlmodel.select(sql.Release)
+            .join(sql.Project, via(sql.Release.project_key) == via(sql.Project.key))
+            .where(via(sql.Release.phase).in_(list(_UNFINISHED_PHASES)))
+            .where(via(sql.Project.status) == sql.ProjectStatus.ACTIVE)
+            .options(db.joined_load_nested(sql.Release.project, sql.Project.committee))
+        )
+        result = await data.execute(query)
+        releases: Sequence[sql.Release] = list(result.scalars().all())
+
+        for release in releases:
+            try:
+                plan = classify(release, now=now)
+            except Exception:
+                log.exception(f"Inactivity classification failed for release {release.key!r}")
+                continue
+            plans.append(plan)
+            if plan.decision != Decision.SKIP:
+                log.info(
+                    "Inactivity scan decision"
+                    f" release={plan.release_key!r}"
+                    f" phase={plan.phase.value}"
+                    f" decision={plan.decision.value}"
+                    f" activity_at={plan.activity_at.isoformat()}"
+                )
+
+    counts: dict[str, int] = {}
+    for plan in plans:
+        counts[plan.decision.value] = counts.get(plan.decision.value, 0) + 1
+    log.info(f"Inactivity scan summary total={len(plans)} deletion_enabled={_DELETION_ENABLED} counts={counts}")
+    return plans
+
+
+def thresholds() -> tuple[int, int]:
+    return constants.INACTIVITY_WARNING_DAYS, constants.INACTIVITY_DELETE_DAYS
+
+
+async def warning_recipients_for(release: sql.Release, data: db.Session) -> list[str]:
+    via = sql.validate_instrumented_attribute
+    query = (
+        sqlmodel.select(via(sql.Revision.asfuid))
+        .where(sql.Revision.release_key == release.key)
+        .where(via(sql.Revision.asfuid) != constants.SYSTEM_SERVICE_UID)
+        .distinct()
+    )
+    result = await data.execute(query)
+    uids: list[str] = sorted({row for row in result.scalars().all() if row})
+    if uids:
+        return [f"{uid}@apache.org" for uid in uids]
+    return [private_committee_list(_committee_key_for(release))]
+
+
+def _committee_key_for(release: sql.Release) -> str:
+    project = release.project
+    if project.committee is not None:
+        return project.committee.key
+    return project.committee_key or release.project_key
+
+
+async def _delete_or_dry_run(plan: Plan) -> None:
+    async with storage.write_as_system() as release_admin:
+        error = await release_admin.delete_inactive(
+            project_key=safe.ProjectKey(plan.project_key),
+            version=safe.VersionKey(plan.version),
+            dry_run=not _DELETION_ENABLED,
+        )
+    if error:
+        log.info(f"Inactivity deletion skipped for {plan.release_key!r}: {error}")
+        return
+    if _DELETION_ENABLED:
+        log.info(f"Inactivity deletion succeeded for {plan.release_key!r}")
+    else:
+        log.info(
+            "Inactivity dry-run would_delete"
+            f" release={plan.release_key!r}"
+            f" phase={plan.phase.value}"
+            f" activity_at={plan.activity_at.isoformat()}"
+        )
+
+
+async def _load_release(data: db.Session, release_key: str) -> sql.Release | None:
+    return await data.release(
+        key=release_key,
+        _committee=True,
+    ).get()
+
+
+def _notice_already_recorded(stored: str | None, expected: str) -> bool:
+    return stored == expected
+
+
+def _notice_key(kind: str, activity_at: datetime.datetime) -> str:
+    return f"{kind}:{activity_at.isoformat()}"
+
+
+def _plan(release: sql.Release, activity_at: datetime.datetime, decision: Decision) -> Plan:
+    return Plan(
+        release_key=release.key,
+        project_key=release.project_key,
+        version=release.version,
+        phase=release.phase,
+        activity_at=activity_at,
+        decision=decision,
+    )
+
+
+def _plan_still_current(plan: Plan, release: sql.Release, *, expected_kind: str) -> bool:
+    project_status = release.project.status
+    if project_status != sql.ProjectStatus.ACTIVE:
+        log.info(
+            f"Inactivity {expected_kind} skipped for {plan.release_key!r}:"
+            f" project {release.project_key!r} status changed to {project_status.value}"
+        )
+        return False
+    if expected_kind == NOTICE_KIND_PREVIEW_ESCALATION:
+        expected_phase_ok = release.phase == sql.ReleasePhase.RELEASE_PREVIEW
+        activity_ok = release.activity_at == plan.activity_at
+    else:
+        expected_phase_ok = release.phase in _UNFINISHED_PHASES
+        activity_ok = release.activity_at == plan.activity_at
+    if not expected_phase_ok:
+        log.info(f"Inactivity {expected_kind} skipped for {plan.release_key!r}: phase changed to {release.phase.value}")
+        return False
+    if not activity_ok:
+        log.info(
+            f"Inactivity {expected_kind} skipped for {plan.release_key!r}:"
+            f" activity_at advanced to {release.activity_at.isoformat()}"
+        )
+        return False
+    return True
+
+
+def _preview_escalation_body(plan: Plan) -> str:
+    _warning_days, delete_days = thresholds()
+    return (
+        f"As of the end of May 2026, ATR is enacting a new policy where release\n"
+        f"previews inactive for more than {delete_days} days are flagged. This release\n"
+        f"preview has now been inactive for at least {delete_days} days, and is being\n"
+        f"flagged. Release previews are not automatically deleted. The Finish\n"
+        f"phase has a form to reset the inactivity clock. Please complete the\n"
+        f"finish step or cancel the release.\n\n"
+        f"Project: {plan.project_key}\n"
+        f"Version: {plan.version}\n"
+        f"Phase: {plan.phase.value}\n"
+    )
+
+
+async def _record_notice_sent(release_key: str, kind: str, activity_at: datetime.datetime) -> None:
+    async with db.session() as data:
+        release = await data.release(key=release_key).get()
+        if release is None:
+            return
+        if release.activity_at != activity_at:
+            return
+        release.inactivity_notice_key = _notice_key(kind, activity_at)
+        await data.commit()
+
+
+async def _send_email(*, recipient: str, subject: str, body: str) -> bool:
+    message = mail.Message(
+        email_sender=mail.NOREPLY_EMAIL_ADDRESS,
+        email_to=recipient,
+        subject=subject,
+        body=body,
+    )
+    try:
+        if config.is_dev_environment():
+            log.info(f"Dev environment detected, not sending inactivity email to {recipient!r}")
+            mid: str | None = message.message_id
+            errors: list[str] = []
+        else:
+            mid, errors = await mail.send(message, mail.MailFooterCategory.AUTO)
+    except Exception:
+        log.exception(f"Inactivity mail send failed for {recipient!r} subject={subject!r}")
+        return False
+    storage.audit(
+        asf_uid=constants.SYSTEM_SERVICE_UID,
+        email_sender=message.email_sender,
+        email_to=message.email_to,
+        subject=message.subject,
+        mid=mid,
+        errors=", ".join(errors) if errors else "",
+    )
+    if errors:
+        log.warning(
+            f"Inactivity mail to {recipient!r} reported SMTP errors {errors};"
+            f" treating as unsuccessful so the notice marker is not recorded"
+        )
+        return False
+    return True
+
+
+async def _send_preview_escalation(plan: Plan) -> None:
+    expected = _notice_key(NOTICE_KIND_PREVIEW_ESCALATION, plan.activity_at)
+    async with db.session() as data:
+        release = await _load_release(data, plan.release_key)
+        if release is None:
+            log.warning(f"Inactivity escalation: release {plan.release_key!r} no longer exists")
+            return
+        if not _plan_still_current(plan, release, expected_kind=NOTICE_KIND_PREVIEW_ESCALATION):
+            return
+        if _notice_already_recorded(release.inactivity_notice_key, expected):
+            log.info(
+                f"Inactivity preview escalation already sent for {plan.release_key!r} at {plan.activity_at.isoformat()}"
+            )
+            return
+        recipient = private_committee_list(_committee_key_for(release))
+
+    body = _preview_escalation_body(plan)
+    sent = await _send_email(
+        recipient=recipient,
+        subject=f"[NOTICE] Release preview inactive: {plan.project_key} {plan.version}",
+        body=body,
+    )
+    if sent:
+        await _record_notice_sent(plan.release_key, NOTICE_KIND_PREVIEW_ESCALATION, plan.activity_at)
+
+
+async def _send_warning(plan: Plan) -> None:
+    expected = _notice_key(NOTICE_KIND_WARNING, plan.activity_at)
+    async with db.session() as data:
+        release = await _load_release(data, plan.release_key)
+        if release is None:
+            log.warning(f"Inactivity warning: release {plan.release_key!r} no longer exists")
+            return
+        if not _plan_still_current(plan, release, expected_kind=NOTICE_KIND_WARNING):
+            return
+        if _notice_already_recorded(release.inactivity_notice_key, expected):
+            log.info(f"Inactivity warning already sent for {plan.release_key!r} at {plan.activity_at.isoformat()}")
+            return
+        recipients = await warning_recipients_for(release, data)
+
+    body = _warning_body(plan)
+    subject = f"[WARNING] Release inactive: {plan.project_key} {plan.version}"
+    all_sent = True
+    for recipient in recipients:
+        if not await _send_email(recipient=recipient, subject=subject, body=body):
+            all_sent = False
+    if all_sent and recipients:
+        await _record_notice_sent(plan.release_key, NOTICE_KIND_WARNING, plan.activity_at)
+    elif recipients:
+        log.warning(
+            f"Inactivity warning for {plan.release_key!r} had at least one recipient failure;"
+            f" notice not recorded so the next maintenance run retries the full set"
+        )
+
+
+def _warning_body(plan: Plan) -> str:
+    warn_days, delete_days = thresholds()
+    remaining_days = delete_days - warn_days
+    if plan.phase == sql.ReleasePhase.RELEASE_PREVIEW:
+        return (
+            f"As of the end of May 2026, ATR is enacting a new policy where release\n"
+            f"previews inactive for more than {delete_days} days are flagged. Release\n"
+            f"previews are not automatically deleted. This message is being sent at\n"
+            f"{warn_days} days (or more) to warn you of this. The Finish phase has a form\n"
+            f"to reset the inactivity clock to avoid this release being flagged in\n"
+            f"{remaining_days} days.\n\n"
+            f"Project: {plan.project_key}\n"
+            f"Version: {plan.version}\n"
+            f"Phase: {plan.phase.value}\n"
+        )
+    return (
+        f"As of the end of May 2026, ATR is enacting a new policy where releases\n"
+        f"inactive for more than {delete_days} days are deleted. This message is being\n"
+        f"sent at {warn_days} days (or more) to warn you of this. The Compose and Finish\n"
+        f"phases have a form to reset the inactivity clock if you want to keep\n"
+        f"this release. Otherwise it will be deleted in {remaining_days} days.\n\n"
+        f"Project: {plan.project_key}\n"
+        f"Version: {plan.version}\n"
+        f"Phase: {plan.phase.value}\n"
+    )
diff --git a/atr/tasks/maintenance.py b/atr/tasks/maintenance.py
index 50b23d28..afa3fecd 100644
--- a/atr/tasks/maintenance.py
+++ b/atr/tasks/maintenance.py
@@ -28,6 +28,7 @@ import atr.models.results as results
 import atr.models.sql as sql
 import atr.tasks as tasks
 import atr.tasks.checks as checks
+import atr.tasks.inactivity as inactivity
 
 _EXPIRED_TOKEN_RETENTION_DAYS: Final[int] = 30
 _FORM_ERROR_TTL_SECONDS: Final[int] = 24 * 60 * 60
@@ -51,6 +52,7 @@ async def run(task_args: args.MaintenanceArgs) -> results.Results | None:
         await _session_data_maintenance()
         await _storage_maintenance()
         await _workflow_ssh_keys_maintenance()
+        await _inactivity_maintenance()
 
         log.info(
             "Storage maintenance completed successfully",
@@ -77,6 +79,21 @@ async def _expired_pats_maintenance() -> None:
             log.info(f"Purged {deleted} expired personal access tokens")
 
 
+async def _inactivity_maintenance() -> None:
+    try:
+        plans = await inactivity.run_scan()
+    except Exception:
+        log.exception("Inactivity scan failed")
+        return
+    for plan in plans:
+        if plan.decision == inactivity.Decision.SKIP:
+            continue
+        try:
+            await inactivity.apply_plan(plan)
+        except Exception:
+            log.exception(f"Inactivity action failed for release {plan.release_key!r}")
+
+
 async def _session_data_maintenance() -> None:
     via = sql.validate_instrumented_attribute
     cutoff = time.time() - _FORM_ERROR_TTL_SECONDS
diff --git a/atr/tasks/quarantine.py b/atr/tasks/quarantine.py
index 6eda498a..06b16ed1 100644
--- a/atr/tasks/quarantine.py
+++ b/atr/tasks/quarantine.py
@@ -33,6 +33,7 @@ import exarch
 import atr.archives as archives
 import atr.attestable as attestable
 import atr.config as config
+import atr.constants as constants
 import atr.db as db
 import atr.detection as detection
 import atr.hashes as hashes
@@ -302,7 +303,7 @@ async def _mark_failed(
         log.error(f"Quarantine {quarantined.id} failed: {message}")
     else:
         log.error(f"Quarantine {quarantined.id} failed safety checks")
-    if (not asf_uid) or (asf_uid == "system"):
+    if (not asf_uid) or (asf_uid == constants.SYSTEM_SERVICE_UID):
         return
     reason = message or "safety checks failed"
     location_parts = [part for part in (project_key, version_key) if part]
diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html
index 703eaa0a..ca6fb8c8 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -144,6 +144,9 @@
       {% endif %}
     </div>
     <h2 id="more-actions">More actions</h2>
+    {% if activity_form %}
+      <div class="mb-2">{{ activity_form|safe }}</div>
+    {% endif %}
     {% if is_viewing_as_admin_fn(current_user.uid) %}
       <h3 id="cache-control" class="mt-4">Check cache control</h3>
       <p>Each button creates a new revision and restarts all checks.</p>
diff --git a/atr/user.py b/atr/user.py
index 771fd093..8b519916 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -20,6 +20,7 @@
 from __future__ import annotations
 
 import functools
+from typing import TYPE_CHECKING
 
 import atr.cache as cache
 import atr.config as config
@@ -27,6 +28,9 @@ import atr.db as db
 import atr.models.sql as sql
 import atr.util as util
 
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+
 
 def binding_terminology(vote_round: int | None) -> tuple[str, str]:
     if vote_round == 1:
@@ -106,6 +110,14 @@ def is_committer(committee: sql.Committee | None, uid: str) -> bool:
     return any((committer_uid == uid) for committer_uid in committee.committers)
 
 
+def is_participant_for_committee(committee: sql.Committee | None, project_keys: Iterable[str]) -> bool:
+    if committee is None:
+        return False
+    if config.is_test_mode() and (committee.key == "test"):
+        return True
+    return committee.key in project_keys
+
+
 async def projects(uid: str, committee_only: bool = False, super_project: bool = False) -> list[sql.Project]:
     user_projects: list[sql.Project] = []
     async with db.session() as data:
diff --git a/atr/worker.py b/atr/worker.py
index de9ec27a..29d0931d 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -34,6 +34,7 @@ from typing import Any, Final
 
 import sqlmodel
 
+import atr.constants as constants
 import atr.db as db
 import atr.ldap as ldap
 import atr.log as log
@@ -157,7 +158,7 @@ async def _notify_task_failure(
     primary_rel_path: str | None,
     error: str,
 ) -> None:
-    if (not asf_uid) or (asf_uid == "system"):
+    if (not asf_uid) or (asf_uid == constants.SYSTEM_SERVICE_UID):
         return
     message = _task_failure_message(task_type, project_key, version_key, revision_number, primary_rel_path, error)
     try:
@@ -284,7 +285,7 @@ async def _task_process(task_id: int, task_type: str, task_args: list[str] | dic
     task_results: results.Results | None
     try:
         if (
-            asf_uid != "system"
+            asf_uid != constants.SYSTEM_SERVICE_UID
             and not (config.is_test_mode() and asf_uid == "test")
             and (config.is_production_mode() or config.is_ldap_configured())
         ):
diff --git a/migrations/versions/0090_2026.05.29_a13b8c92.py b/migrations/versions/0090_2026.05.29_a13b8c92.py
new file mode 100644
index 00000000..2f431dcd
--- /dev/null
+++ b/migrations/versions/0090_2026.05.29_a13b8c92.py
@@ -0,0 +1,96 @@
+# 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.
+
+"""Release inactivity tracking
+
+Revision ID: 0090_2026.05.29_a13b8c92
+Revises: 0089_2026.05.28_c1b6a816
+Create Date: 2026-05-29 16:38:10.011772+00:00
+"""
+
+import datetime
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.models.sql as sql
+
+# Revision identifiers, used by Alembic
+revision: str = "0090_2026.05.29_a13b8c92"
+down_revision: str | None = "0089_2026.05.28_c1b6a816"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    backfill = datetime.datetime.now(datetime.UTC)
+    initial_warning = backfill - datetime.timedelta(days=80)
+    utc_datetime = sql.UTCDateTime(timezone=True)
+
+    with op.batch_alter_table("release", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column(
+                "activity_at",
+                sql.UTCDateTime(timezone=True),
+                nullable=False,
+                server_default=sa.text("CURRENT_TIMESTAMP"),
+            )
+        )
+        batch_op.add_column(sa.Column("inactivity_notice_key", sa.String(), nullable=True))
+
+    op.execute(
+        sa.text("UPDATE release SET activity_at = :ts").bindparams(
+            sa.bindparam("ts", value=backfill, type_=utc_datetime)
+        )
+    )
+    op.execute(
+        sa.text(
+            """
+            UPDATE release
+            SET activity_at = :initial_warning
+            WHERE phase IN ('RELEASE_CANDIDATE_DRAFT', 'RELEASE_CANDIDATE', 'RELEASE_PREVIEW')
+            AND (
+                SELECT max(activity_at)
+                FROM (
+                    SELECT release.created AS activity_at
+                    UNION ALL
+                    SELECT max(revision.created) AS activity_at
+                    FROM revision
+                    WHERE revision.release_key = release.key
+                    UNION ALL
+                    SELECT release.vote_started AS activity_at
+                    WHERE release.vote_started IS NOT NULL
+                    UNION ALL
+                    SELECT release.vote_resolved AS activity_at
+                    WHERE release.vote_resolved IS NOT NULL
+                )
+            ) <= :initial_warning
+            """
+        ).bindparams(
+            sa.bindparam("initial_warning", value=initial_warning, type_=utc_datetime),
+        )
+    )
+
+    with op.batch_alter_table("release", schema=None) as batch_op:
+        batch_op.alter_column("activity_at", server_default=None)
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("release", schema=None) as batch_op:
+        batch_op.drop_column("inactivity_notice_key")
+        batch_op.drop_column("activity_at")
diff --git a/tests/unit/test_archived_project_writer_guards.py b/tests/unit/test_archived_project_writer_guards.py
index b7b2aafc..52043e2f 100644
--- a/tests/unit/test_archived_project_writer_guards.py
+++ b/tests/unit/test_archived_project_writer_guards.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
 import unittest.mock as mock
 from types import SimpleNamespace
 
@@ -121,6 +122,276 @@ async def test_distributions_record_blocks_retired_via_release_lookup() -> None:
         )
 
 
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_acquires_lock_and_rechecks_before_delete() -> None:
+    import datetime as _dt
+
+    active_project = SimpleNamespace(
+        key="project",
+        status=sql.ProjectStatus.ACTIVE,
+        committee_key="project",
+        display_name="Project",
+        short_display_name="Project",
+    )
+    eligible_release = SimpleNamespace(
+        key=sql.release_key("project", "1.0.0"),
+        project=active_project,
+        project_key="project",
+        version="1.0.0",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+    )
+    data = mock.MagicMock()
+    data.release = mock.MagicMock(return_value=_query_returning(eligible_release))
+    data.begin_immediate = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+    data.expire_all = mock.MagicMock()
+    data.execute = mock.AsyncMock(return_value=mock.MagicMock(scalar_one=mock.MagicMock(return_value=0)))
+
+    writer = object.__new__(release.FoundationAdmin)
+    writer._FoundationAdmin__data = data
+    writer._FoundationAdmin__write_as = mock.MagicMock()
+    delete = mock.AsyncMock(return_value=None)
+    object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+    result = await writer.delete_inactive(
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        dry_run=False,
+    )
+    assert result is None
+    data.begin_immediate.assert_awaited_once()
+    data.expire_all.assert_called_once()
+    delete.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_refresh_catches_concurrent_phase_change() -> None:
+    import datetime as _dt
+
+    active_project = SimpleNamespace(
+        key="project",
+        status=sql.ProjectStatus.ACTIVE,
+        committee_key="project",
+        display_name="Project",
+        short_display_name="Project",
+    )
+    stale_release = SimpleNamespace(
+        key=sql.release_key("project", "1.0.0"),
+        project=active_project,
+        project_key="project",
+        version="1.0.0",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+    )
+    fresh_release = SimpleNamespace(
+        key=sql.release_key("project", "1.0.0"),
+        project=active_project,
+        project_key="project",
+        version="1.0.0",
+        phase=sql.ReleasePhase.RELEASE_PREVIEW,
+        activity_at=_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=120),
+    )
+
+    data = mock.MagicMock()
+    expired = {"called": False}
+
+    def _on_expire_all() -> None:
+        expired["called"] = True
+
+    def _release_query(**kwargs: object) -> mock.MagicMock:
+        if expired["called"]:
+            return _query_returning(fresh_release)
+        return _query_returning(stale_release)
+
+    data.release = mock.MagicMock(side_effect=_release_query)
+    data.begin_immediate = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+    data.expire_all = mock.MagicMock(side_effect=_on_expire_all)
+    data.execute = mock.AsyncMock(return_value=mock.MagicMock(scalar_one=mock.MagicMock(return_value=0)))
+
+    writer = object.__new__(release.FoundationAdmin)
+    writer._FoundationAdmin__data = data
+    writer._FoundationAdmin__write_as = mock.MagicMock()
+    delete = mock.AsyncMock(return_value=None)
+    object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+    result = await writer.delete_inactive(
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        dry_run=False,
+    )
+    assert result is not None and "not eligible" in result
+    data.begin_immediate.assert_awaited_once()
+    data.expire_all.assert_called_once()
+    data.rollback.assert_awaited_once()
+    delete.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_foundation_admin_delete_inactive_rejects_non_active_project() -> None:
+    import datetime as _dt
+
+    retired_release = SimpleNamespace(
+        key=sql.release_key("project", "1.0.0"),
+        project=_retired_project(),
+        project_key="project",
+        version="1.0.0",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=_dt.datetime(2025, 1, 1, tzinfo=_dt.UTC),
+    )
+    data = mock.MagicMock()
+    data.release = mock.MagicMock(return_value=_query_returning(retired_release))
+    data.begin_immediate = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+
+    writer = object.__new__(release.FoundationAdmin)
+    writer._FoundationAdmin__data = data
+    writer._FoundationAdmin__write_as = mock.MagicMock()
+    delete = mock.AsyncMock(return_value=None)
+    object.__setattr__(writer, "_FoundationAdmin__delete", delete)
+
+    error = await writer.delete_inactive(
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        dry_run=True,
+    )
+    assert error is not None and "not active" in error
+    delete.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_blocks_ineligible_phase() -> None:
+    data = mock.MagicMock()
+    candidate = _active_release(phase=sql.ReleasePhase.RELEASE)
+    data.release = mock.MagicMock(return_value=_query_returning(candidate))
+    data.commit = mock.AsyncMock()
+    data.execute = mock.AsyncMock()
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+
+    with pytest.raises(storage.AccessError, match="not eligible"):
+        await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    data.execute.assert_not_awaited()
+    data.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_blocks_retired() -> None:
+    data = mock.MagicMock()
+    retired = _retired_release()
+    retired.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+    retired.inactivity_notice_key = "warning:old"
+    retired.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+    data.release = mock.MagicMock(return_value=_query_returning(retired))
+    data.commit = mock.AsyncMock()
+    data.execute = mock.AsyncMock()
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+
+    with pytest.raises(storage.AccessError, match="archived"):
+        await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    data.execute.assert_not_awaited()
+    data.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_clears_future_notice() -> None:
+    data = mock.MagicMock()
+    future_activity = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
+    candidate = _active_release(activity_at=future_activity)
+    data.release = mock.MagicMock(return_value=_query_returning(candidate))
+    execute_result = mock.MagicMock()
+    execute_result.rowcount = 1
+    data.execute = mock.AsyncMock(return_value=execute_result)
+    data.refresh = mock.AsyncMock(side_effect=lambda obj: _refresh_release_after_bump(obj, future_activity))
+    data.commit = mock.AsyncMock()
+    write_as = mock.MagicMock()
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__write_as = write_as
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+
+    result = await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    assert result is candidate
+    assert candidate.activity_at is future_activity
+    assert candidate.inactivity_notice_key is None
+    data.execute.assert_awaited_once()
+    data.refresh.assert_awaited_once_with(candidate)
+    data.commit.assert_awaited_once()
+    write_as.append_to_audit_log.assert_called_once_with(
+        asf_uid="tester",
+        project_key="project",
+        version="1.0.0",
+        previous_activity_at=future_activity.isoformat(),
+        activity_at=future_activity.isoformat(),
+    )
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_rejects_concurrent_state_change() -> None:
+    data = mock.MagicMock()
+    candidate = _active_release()
+    data.release = mock.MagicMock(return_value=_query_returning(candidate))
+    execute_result = mock.MagicMock()
+    execute_result.rowcount = 0
+    data.execute = mock.AsyncMock(return_value=execute_result)
+    data.rollback = mock.AsyncMock()
+    data.commit = mock.AsyncMock()
+    write_as = mock.MagicMock()
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__write_as = write_as
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+
+    with pytest.raises(storage.AccessError, match="state has changed"):
+        await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    data.rollback.assert_awaited_once()
+    data.commit.assert_not_awaited()
+    write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_bump_activity_updates_activity() -> None:
+    old_activity = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=85)
+    new_activity = old_activity + datetime.timedelta(days=85)
+    candidate = _active_release(activity_at=old_activity)
+    data = mock.MagicMock()
+    data.release = mock.MagicMock(return_value=_query_returning(candidate))
+    execute_result = mock.MagicMock()
+    execute_result.rowcount = 1
+    data.execute = mock.AsyncMock(return_value=execute_result)
+    data.refresh = mock.AsyncMock(side_effect=lambda obj: _refresh_release_after_bump(obj, new_activity))
+    data.commit = mock.AsyncMock()
+    write_as = mock.MagicMock()
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__write_as = write_as
+    writer._CommitteeParticipant__asf_uid = "tester"
+    writer._CommitteeParticipant__committee_key = "project"
+
+    result = await writer.bump_activity(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    assert result is candidate
+    assert candidate.activity_at == new_activity
+    assert candidate.inactivity_notice_key is None
+    data.execute.assert_awaited_once()
+    data.refresh.assert_awaited_once_with(candidate)
+    data.commit.assert_awaited_once()
+    write_as.append_to_audit_log.assert_called_once_with(
+        asf_uid="tester",
+        project_key="project",
+        version="1.0.0",
+        previous_activity_at=old_activity.isoformat(),
+        activity_at=new_activity.isoformat(),
+    )
+
+
 @pytest.mark.asyncio
 async def test_release_delete_blocks_retired() -> None:
     data = mock.MagicMock()
@@ -273,6 +544,35 @@ async def test_vote_send_resolution_blocks_retired() -> None:
         )
 
 
+def _active_project(key: str = "project") -> SimpleNamespace:
+    return SimpleNamespace(
+        key=key,
+        status=sql.ProjectStatus.ACTIVE,
+        is_active=True,
+        committee_key=key,
+        display_name=key.capitalize(),
+        short_display_name=key.capitalize(),
+    )
+
+
+def _active_release(
+    phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+    activity_at: datetime.datetime | None = None,
+) -> SimpleNamespace:
+    activity_at_missing = activity_at is None
+    if activity_at_missing:
+        activity_at = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=85)
+    return SimpleNamespace(
+        key=sql.release_key("project", "1.0.0"),
+        project=_active_project(),
+        project_key="project",
+        version="1.0.0",
+        phase=phase,
+        activity_at=activity_at,
+        inactivity_notice_key="warning:old",
+    )
+
+
 def _async_context_manager(value: object) -> mock.MagicMock:
     ctx = mock.MagicMock()
     ctx.__aenter__ = mock.AsyncMock(return_value=value)
@@ -287,6 +587,11 @@ def _query_returning(obj: object) -> mock.MagicMock:
     return query
 
 
+def _refresh_release_after_bump(release_obj: SimpleNamespace, activity_at: datetime.datetime) -> None:
+    release_obj.activity_at = activity_at
+    release_obj.inactivity_notice_key = None
+
+
 def _retired_project(key: str = "project") -> SimpleNamespace:
     return SimpleNamespace(
         key=key,
diff --git a/tests/unit/test_create_revision.py b/tests/unit/test_create_revision.py
index 1173a68f..8add4c24 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
 import os
 import pathlib
 import unittest.mock as mock
@@ -114,6 +115,7 @@ async def test_clone_from_older_revision_skips_merge_without_intervening_change(
     release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
+    release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
     release_key = sql.release_key("proj", "1.0")
 
     latest_revision = mock.MagicMock()
@@ -208,6 +210,7 @@ async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_pa
     release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
+    release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
     release_key = sql.release_key("proj", "1.0")
 
     old_revision = mock.MagicMock()
@@ -284,6 +287,7 @@ async def test_intervening_revision_with_path_provenance_verifies_sources(tmp_pa
     release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
+    release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
     release_key = sql.release_key("proj", "1.0")
 
     old_revision = mock.MagicMock()
@@ -439,6 +443,7 @@ async def test_modify_path_provenance_flows_into_write_files_data(tmp_path: path
     release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
+    release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
 
     import contextlib
 
@@ -588,6 +593,7 @@ async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathl
     release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
+    release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
     release_key = sql.release_key("proj", "1.0")
 
     old_revision = mock.MagicMock()
diff --git a/tests/unit/test_inactivity.py b/tests/unit/test_inactivity.py
new file mode 100644
index 00000000..465af0eb
--- /dev/null
+++ b/tests/unit/test_inactivity.py
@@ -0,0 +1,443 @@
+# 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.
+
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import unittest.mock as mock
+from typing import Any
+
+import pytest
+
+import atr.models.sql as sql
+import atr.tasks.inactivity as inactivity
+
+
+@dataclasses.dataclass
+class FakeProject:
+    key: str = "example"
+    status: sql.ProjectStatus = sql.ProjectStatus.ACTIVE
+    committee_key: str = "example"
+
+
+@dataclasses.dataclass
+class FakeRelease:
+    key: str
+    project_key: str
+    version: str
+    phase: sql.ReleasePhase
+    activity_at: datetime.datetime
+    vote_started: datetime.datetime | None = None
+    vote_resolved: datetime.datetime | None = None
+    revisions: list[Any] = dataclasses.field(default_factory=list)
+    project: FakeProject = dataclasses.field(default_factory=FakeProject)
+
+
+def test_classify_overdue_candidate_is_delete_candidate() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE, activity_age_days=120)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.DELETE_CANDIDATE
+
+
+def test_classify_overdue_draft_is_delete_candidate() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=100)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.DELETE_CANDIDATE
+
+
+def test_classify_overdue_preview_escalates() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=200)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.PREVIEW_ESCALATE
+
+
+def test_classify_preview_warning_window_warns() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=85)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.WARN
+    assert plan.phase == sql.ReleasePhase.RELEASE_PREVIEW
+
+
+def test_classify_release_phase_skips() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE, activity_age_days=500)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.SKIP
+
+
+def test_classify_under_warning_skips() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=10)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.SKIP
+
+
+def test_classify_warning_boundary_warns() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=80)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.WARN
+
+
+def test_classify_warning_zone_warns() -> None:
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+    plan = inactivity.classify(release, now=_now())
+    assert plan.decision == inactivity.Decision.WARN
+
+
+def test_deletion_enabled_by_default() -> None:
+    assert inactivity.deletion_enabled() is True
+
+
+def test_notice_already_recorded_none_returns_false() -> None:
+    assert inactivity._notice_already_recorded(None, "warning:x") is False
+
+
+def test_notice_already_recorded_requires_exact_match_for_normal_warning() -> None:
+    stored = "warning:2026-02-01T00:00:00+00:00"
+    fresh = "warning:2026-03-01T00:00:00+00:00"
+    assert inactivity._notice_already_recorded(stored, fresh) is False
+    assert inactivity._notice_already_recorded(stored, stored) is True
+
+
+def test_notice_key_format_round_trips_activity_at() -> None:
+    activity_at = datetime.datetime(2026, 5, 21, 0, 0, tzinfo=datetime.UTC)
+    key = inactivity._notice_key(inactivity.NOTICE_KIND_WARNING, activity_at)
+    assert key == f"{inactivity.NOTICE_KIND_WARNING}:{activity_at.isoformat()}"
+
+
+def test_plan_still_current_allows_preview_warning() -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_PREVIEW,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+    release = _release(phase=sql.ReleasePhase.RELEASE_PREVIEW, activity_age_days=85)
+    release.activity_at = activity_at
+    assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is True
+
+
+def test_plan_still_current_passes_for_matching_state() -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+    release.activity_at = activity_at
+    assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is True
+
+
+def test_plan_still_current_skips_on_activity_advance() -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+    release = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=1)
+    assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_on_archived_project() -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+    release = _release(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_age_days=85,
+        project_status=sql.ProjectStatus.RETIRED,
+    )
+    release.activity_at = activity_at
+    assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_on_phase_change() -> None:
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=_now() - datetime.timedelta(days=85),
+        decision=inactivity.Decision.WARN,
+    )
+    release = _release(phase=sql.ReleasePhase.RELEASE, activity_age_days=85)
+    assert inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_WARNING) is False
+
+
+def test_plan_still_current_skips_preview_escalation_on_archived_project() -> None:
+    activity_at = _now() - datetime.timedelta(days=120)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_PREVIEW,
+        activity_at=activity_at,
+        decision=inactivity.Decision.PREVIEW_ESCALATE,
+    )
+    release = _release(
+        phase=sql.ReleasePhase.RELEASE_PREVIEW,
+        activity_age_days=120,
+        project_status=sql.ProjectStatus.RETIRED,
+    )
+    release.activity_at = activity_at
+    assert (
+        inactivity._plan_still_current(plan, release, expected_kind=inactivity.NOTICE_KIND_PREVIEW_ESCALATION) is False
+    )
+
+
+def test_private_committee_list_format() -> None:
+    assert inactivity.private_committee_list("example") == "private@example.apache.org"
+
+
+@pytest.mark.asyncio
+async def test_send_email_returns_false_on_smtp_errors(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+    monkeypatch.setattr(inactivity.storage, "audit", mock.MagicMock())
+    monkeypatch.setattr(
+        inactivity.mail,
+        "send",
+        mock.AsyncMock(return_value=("mid-456", ["550 rejected"])),
+    )
+    sent = await inactivity._send_email(recipient="alice@apache.org", subject="x", body="y")
+    assert sent is False
+
+
+@pytest.mark.asyncio
+async def test_send_email_returns_true_when_no_errors(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+    monkeypatch.setattr(inactivity.storage, "audit", mock.MagicMock())
+    monkeypatch.setattr(inactivity.mail, "send", mock.AsyncMock(return_value=("mid-789", [])))
+    sent = await inactivity._send_email(recipient="alice@apache.org", subject="x", body="y")
+    assert sent is True
+
+
+@pytest.mark.asyncio
+async def test_send_warning_does_not_record_on_partial_recipient_failure(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+
+    release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+    release_stub.activity_at = activity_at
+    release_stub.inactivity_notice_key = None
+
+    data = mock.MagicMock()
+    monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+    monkeypatch.setattr(
+        inactivity,
+        "warning_recipients_for",
+        mock.AsyncMock(return_value=["alice@apache.org", "bob@apache.org"]),
+    )
+    monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+    send_mock = mock.AsyncMock(side_effect=[True, False])
+    monkeypatch.setattr(inactivity, "_send_email", send_mock)
+    record_mock = mock.AsyncMock()
+    monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+    await inactivity._send_warning(plan)
+
+    assert send_mock.await_count == 2
+    record_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_warning_does_not_record_when_mail_send_returns_smtp_errors(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+
+    release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+    release_stub.activity_at = activity_at
+    release_stub.inactivity_notice_key = None
+
+    data = mock.MagicMock()
+    monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+    monkeypatch.setattr(
+        inactivity,
+        "warning_recipients_for",
+        mock.AsyncMock(return_value=["alice@apache.org"]),
+    )
+    monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+    audit_mock = mock.MagicMock()
+    monkeypatch.setattr(inactivity.storage, "audit", audit_mock)
+    monkeypatch.setattr(inactivity.config, "is_dev_environment", lambda: False)
+
+    mail_send = mock.AsyncMock(return_value=("mid-123", ["550 mailbox unavailable"]))
+    monkeypatch.setattr(inactivity.mail, "send", mail_send)
+    record_mock = mock.AsyncMock()
+    monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+    await inactivity._send_warning(plan)
+
+    mail_send.assert_awaited_once()
+    audit_mock.assert_called_once()
+    record_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_warning_records_when_all_recipients_succeed(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    activity_at = _now() - datetime.timedelta(days=85)
+    plan = inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
+
+    release_stub = _release(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, activity_age_days=85)
+    release_stub.activity_at = activity_at
+    release_stub.inactivity_notice_key = None
+
+    data = mock.MagicMock()
+    monkeypatch.setattr(inactivity, "_load_release", mock.AsyncMock(return_value=release_stub))
+    monkeypatch.setattr(
+        inactivity,
+        "warning_recipients_for",
+        mock.AsyncMock(return_value=["alice@apache.org", "bob@apache.org"]),
+    )
+    monkeypatch.setattr(inactivity.db, "session", lambda: _async_context_manager(data))
+
+    send_mock = mock.AsyncMock(return_value=True)
+    monkeypatch.setattr(inactivity, "_send_email", send_mock)
+    record_mock = mock.AsyncMock()
+    monkeypatch.setattr(inactivity, "_record_notice_sent", record_mock)
+
+    await inactivity._send_warning(plan)
+
+    assert send_mock.await_count == 2
+    record_mock.assert_awaited_once_with(plan.release_key, inactivity.NOTICE_KIND_WARNING, activity_at)
+
+
+def test_thresholds_are_80_and_90() -> None:
+    warning_days, delete_days = inactivity.thresholds()
+    assert warning_days == 80
+    assert delete_days == 90
+
+
+def test_warning_body_for_candidate_keeps_cleanup_copy() -> None:
+    body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_CANDIDATE))
+    lowered = body.lower()
+    assert "80 days" in lowered
+    assert "will be deleted in 10 days" in lowered
+    assert "flagged" not in lowered
+    assert "2026-02-25" not in lowered
+
+
+def test_warning_body_for_draft_keeps_cleanup_copy() -> None:
+    body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT))
+    lowered = body.lower()
+    assert "80 days" in lowered
+    assert "will be deleted in 10 days" in lowered
+    assert "flagged" not in lowered
+    assert "2026-02-25" not in lowered
+
+
+def test_warning_body_for_preview_does_not_say_deleted() -> None:
+    body = inactivity._warning_body(_warning_plan(sql.ReleasePhase.RELEASE_PREVIEW))
+    lowered = body.lower()
+    assert "80 days" in lowered
+    assert "will be deleted in" not in lowered
+    assert "flagged in\n10 days" in lowered
+    assert "not automatically deleted" in lowered
+    assert "2026-02-25" not in lowered
+
+
+def test_preview_escalation_body_uses_threshold_not_synthetic_date() -> None:
+    body = inactivity._preview_escalation_body(_warning_plan(sql.ReleasePhase.RELEASE_PREVIEW))
+    lowered = body.lower()
+    assert "inactive for at least 90 days" in lowered
+    assert "is being\nflagged" in lowered
+    assert "2026-02-25" not in lowered
+
+
+def _async_context_manager(value: object) -> mock.MagicMock:
+    ctx = mock.MagicMock()
+    ctx.__aenter__ = mock.AsyncMock(return_value=value)
+    ctx.__aexit__ = mock.AsyncMock(return_value=None)
+    return ctx
+
+
+def _now() -> datetime.datetime:
+    return datetime.datetime(2026, 5, 21, 12, 0, tzinfo=datetime.UTC)
+
+
+def _release(
+    *,
+    phase: sql.ReleasePhase,
+    activity_age_days: int,
+    project_status: sql.ProjectStatus = sql.ProjectStatus.ACTIVE,
+) -> FakeRelease:
+    now = _now()
+    activity_at = now - datetime.timedelta(days=activity_age_days)
+    return FakeRelease(
+        key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=phase,
+        activity_at=activity_at,
+        project=FakeProject(key="example", status=project_status, committee_key="example"),
+    )
+
+
+def _warning_plan(phase: sql.ReleasePhase) -> inactivity.Plan:
+    activity_at = _now() - datetime.timedelta(days=85)
+    return inactivity.Plan(
+        release_key="example-0.0.1",
+        project_key="example",
+        version="0.0.1",
+        phase=phase,
+        activity_at=activity_at,
+        decision=inactivity.Decision.WARN,
+    )
diff --git a/tests/unit/test_post_release.py b/tests/unit/test_post_release.py
new file mode 100644
index 00000000..2553cc9c
--- /dev/null
+++ b/tests/unit/test_post_release.py
@@ -0,0 +1,83 @@
+# 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
+
+import pytest
+
+import atr.models.safe as safe
+import atr.post.release as release
+import atr.storage as storage
+
+
+@pytest.mark.asyncio
+async def test_activity_converts_access_error_to_redirect() -> None:
+    redirect_response = mock.MagicMock()
+    session = mock.MagicMock()
+    session.redirect = mock.AsyncMock(return_value=redirect_response)
+    write = mock.MagicMock()
+    write.as_project_committee_participant = mock.AsyncMock(side_effect=storage.AccessError("No access"))
+    context_manager = _async_context_manager(write)
+
+    with mock.patch.object(release.storage, "write", return_value=context_manager):
+        result = await release._bump_activity(
+            session,
+            safe.ProjectKey("project"),
+            safe.VersionKey("1.0.0"),
+        )
+
+    assert result is redirect_response
+    session.redirect.assert_awaited_once_with(release.get.root.index, error="No access")
+
+
+@pytest.mark.asyncio
+async def test_activity_redirects_with_success() -> None:
+    redirect_response = mock.MagicMock()
+    release_row = mock.MagicMock()
+    session = mock.MagicMock()
+    writer = mock.MagicMock()
+    writer.bump_activity = mock.AsyncMock(return_value=release_row)
+    write_as = mock.MagicMock()
+    write_as.release = writer
+    write = mock.MagicMock()
+    write.as_project_committee_participant = mock.AsyncMock(return_value=write_as)
+    context_manager = _async_context_manager(write)
+    flash = mock.AsyncMock()
+    release_as_redirect = mock.AsyncMock(return_value=redirect_response)
+
+    with (
+        mock.patch.object(release.storage, "write", return_value=context_manager),
+        mock.patch.object(release.quart, "flash", flash),
+        mock.patch.object(release.mapping, "release_as_redirect", release_as_redirect),
+    ):
+        result = await release._bump_activity(
+            session,
+            safe.ProjectKey("project"),
+            safe.VersionKey("1.0.0"),
+        )
+
+    assert result is redirect_response
+    writer.bump_activity.assert_awaited_once_with(safe.ProjectKey("project"), safe.VersionKey("1.0.0"))
+    flash.assert_awaited_once_with("Inactivity clock reset", "success")
+    release_as_redirect.assert_awaited_once_with(session, release_row)
+
+
+def _async_context_manager(value: object) -> mock.MagicMock:
+    context_manager = mock.MagicMock()
+    context_manager.__aenter__ = mock.AsyncMock(return_value=value)
+    context_manager.__aexit__ = mock.AsyncMock(return_value=None)
+    return context_manager


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