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/06/12 13:43:40 UTC

(tooling-trusted-releases) branch sbp updated: Add a form to designate non-PMC members as release managers

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

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


The following commit(s) were added to refs/heads/sbp by this push:
     new 60352430 Add a form to designate non-PMC members as release managers
60352430 is described below

commit 60352430a5060afae7d06241a4c5776c22e1722a
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Jun 12 14:42:33 2026 +0100

    Add a form to designate non-PMC members as release managers
---
 atr/api/__init__.py                               |   4 +-
 atr/datasources/apache.py                         |  33 ++--
 atr/db/interaction.py                             |   5 +-
 atr/docs/authorization-security.md                |   4 +-
 atr/docs/input-validation.md                      |   4 +-
 atr/get/committees.py                             |  35 ++++-
 atr/get/projects.py                               |  11 +-
 atr/get/vote.py                                   |  10 +-
 atr/post/__init__.py                              |   2 +
 atr/post/committees.py                            |  79 ++++++++++
 atr/post/manual.py                                |   6 +-
 atr/post/voting.py                                |   4 +-
 atr/shared/__init__.py                            |   2 +
 atr/shared/committees.py                          |  41 +++++
 atr/storage/__init__.py                           |   6 +-
 atr/storage/writers/__init__.py                   |   2 +
 atr/storage/writers/committee.py                  | 166 ++++++++++++++++++++
 atr/storage/writers/release.py                    |  91 ++++++-----
 atr/storage/writers/vote.py                       |  46 +++---
 atr/templates/committee-view.html                 |  29 +++-
 atr/validate.py                                   |  10 +-
 tests/unit/test_archived_project_writer_guards.py |   5 +-
 tests/unit/test_committee_writer.py               | 182 ++++++++++++++++++++++
 tests/unit/test_release_manager_purge.py          |  52 +++++++
 tests/unit/test_release_manager_writer_surface.py |   3 +
 tests/unit/test_vote_end_notify.py                |  10 +-
 tests/unit/test_vote_recipients.py                |  20 +--
 27 files changed, 745 insertions(+), 117 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 03c46341..d89e0c24 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1881,9 +1881,9 @@ async def vote_start(
         util.validate_email_recipients(data)
         util.validate_vote_duration(data.vote_duration)
 
-        async with storage.write_as_committee_participant(committee_key, asf_uid) as wacp:
+        async with storage.write_as_project_release_manager(data.project, asf_uid) as warm:
             # TODO: Get fullname and use instead of asf_uid
-            task = await wacp.vote.start(
+            task = await warm.vote.start(
                 data.email_to,
                 data.project,
                 data.version,
diff --git a/atr/datasources/apache.py b/atr/datasources/apache.py
index 22f5f610..84bc4ef1 100644
--- a/atr/datasources/apache.py
+++ b/atr/datasources/apache.py
@@ -272,17 +272,6 @@ async def get_committee_data() -> dict[str, Committee]:
     return {c.get("id"): Committee.model_validate(c) for c in data}
 
 
-async def get_whimsy_committee_data() -> WhimsyCommitteeData:
-    """Returns the list of currently active committees."""
-
-    async with util.create_secure_session() as session:
-        async with session.get(_WHIMSY_COMMITTEE_INFO_URL) as response:
-            response.raise_for_status()
-            data = await response.json()
-
-    return WhimsyCommitteeData.model_validate(data)
-
-
 async def get_current_podlings_data() -> PodlingsData:
     """Returns the list of current podlings."""
 
@@ -344,6 +333,17 @@ async def get_retired_committee_data() -> RetiredCommitteeData:
     return RetiredCommitteeData.model_validate(data)
 
 
+async def get_whimsy_committee_data() -> WhimsyCommitteeData:
+    """Returns the list of currently active committees."""
+
+    async with util.create_secure_session() as session:
+        async with session.get(_WHIMSY_COMMITTEE_INFO_URL) as response:
+            response.raise_for_status()
+            data = await response.json()
+
+    return WhimsyCommitteeData.model_validate(data)
+
+
 async def update_metadata(include_projects: bool = False) -> tuple[int, int]:
     """Update metadata from remote data sources.
 
@@ -451,6 +451,13 @@ def _project_status(pmc: sql.Committee, project_key: str, project_status: Projec
     return sql.ProjectStatus.ACTIVE
 
 
+def _remove_member_release_managers(committee: sql.Committee) -> None:
+    # PMC members are release managers implicitly, so their designations are removed
+    removed = [uid for uid in committee.release_managers if uid not in committee.committee_members]
+    if removed != committee.release_managers:
+        committee.release_managers = removed
+
+
 async def _update_committees(
     data: db.Session,
     ldap_projects: LDAPProjectsData,
@@ -478,6 +485,7 @@ async def _update_committees(
 
         committee.committee_members = project.owners
         committee.committers = project.members
+        _remove_member_release_managers(committee)
         # We create PMCs for now
         committee.is_podling = False
         whimsy_info = whimsy_committees_by_name.get(name)
@@ -533,6 +541,7 @@ async def _update_podlings(
         if podling_project is not None:
             ppmc.committee_members = podling_project.owners
             ppmc.committers = podling_project.members
+            _remove_member_release_managers(ppmc)
         else:
             log.warning(f"could not find ldap data for podling {podling_name}")
 
@@ -643,7 +652,7 @@ async def _update_tooling(data: db.Session) -> tuple[int, int]:
     tooling_users = list(await ldap.fetch_tooling_users(extra))
     tooling_committee.committee_members = tooling_users
     tooling_committee.committers = tooling_users
-    tooling_committee.release_managers = tooling_users
+    _remove_member_release_managers(tooling_committee)
     tooling_committee.is_podling = False
     tooling_committee.mark_updated(by=constants.SYSTEM_SERVICE_UID, update_type=sql.UpdateType.BOOTSTRAP)
 
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index f7133aaa..c6bfd4fd 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -687,8 +687,9 @@ async def release_ready_to_start_vote(
         return PENDING_QUARANTINE_VOTE_BLOCK_MESSAGE
 
     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"
+    is_release_manager = user.is_release_manager(committee, session.uid)
+    if not (authorisation.is_member_of(committee.key) or is_release_manager or session.is_admin):
+        return "You must be a PMC member or designated release manager of this project to start a vote"
 
     has_files = await util.has_files(release)
     if not has_files:
diff --git a/atr/docs/authorization-security.md b/atr/docs/authorization-security.md
index ed98942d..15bb367d 100644
--- a/atr/docs/authorization-security.md
+++ b/atr/docs/authorization-security.md
@@ -41,7 +41,9 @@ ATR recognizes the following roles, derived from ASF LDAP:
 
 * **Project Participant**: A committer who is a member of a specific project. Can start releases, upload artifacts, and cast votes for that project. Determined by the `member` attribute in the project's LDAP group.
 
-* **PMC Member**: A committer who is on the PMC (Project Management Committee) for a specific committee. Has all participant permissions plus can resolve votes, finish releases, configure project settings, and manage signing keys. Determined by the `owner` attribute in the committee's LDAP group.
+* **Release Manager**: A committer designated by the PMC as a release manager, through the roster on the committee page. Has all participant permissions plus can start and resolve votes, publish and announce releases, record distributions, and edit project metadata and policy, but does not get a binding vote. All PMC members are release managers, so PMC members cannot be designated, and a designation is removed automatically when a designated release manager joins the PMC. Designations a [...]
+
+* **PMC Member**: A committer who is on the PMC (Project Management Committee) for a specific committee. Has all release manager permissions plus can designate release managers, create and archive projects, manage categories and check ignores, and manage signing keys. Determined by the `owner` attribute in the committee's LDAP group.
 
 * **Chair**: A PMC chair. Currently has the same permissions as PMC Member in ATR. Determined by membership in `cn=pmc-chairs,ou=groups,ou=services,dc=apache,dc=org`.
 
diff --git a/atr/docs/input-validation.md b/atr/docs/input-validation.md
index c7b6348e..0a93c24c 100644
--- a/atr/docs/input-validation.md
+++ b/atr/docs/input-validation.md
@@ -255,7 +255,9 @@ Field validation is only the first step. ATR also checks whether an action still
 
 Before a vote can start, [`release_ready_to_start_vote`](/ref/atr/db/interaction.py) (release_ready_to_start_vote) checks that the release still has a latest revision and that the release is still attached to a committee. It also checks that the requested vote mode agrees with the stored project policy, so ATR does not let a user start a manual vote for a project configured for standard voting, or the reverse.
 
-That same validation step then checks the surrounding release state. The user must be a committee member for the project or an ATR administrator. The latest revision must have no blocker results, and the release candidate draft must contain files. When ATR actually promotes the release into the voting phase, [`promote_to_candidate`](/ref/atr/storage/writers/release.py) (promote_to_candidate) adds a task state check and refuses the transition while queued or active tasks still exist for t [...]
+That same validation step then checks the surrounding release state. The user must be a release manager for the project, meaning a PMC member or a designated committer release manager, or an ATR administrator. The latest revision must have no blocker results, and the release candidate draft must contain files. When ATR actually promotes the release into the voting phase, [`promote_to_candidate`](/ref/atr/storage/writers/release.py) (promote_to_candidate) adds a task state check and refus [...]
+
+The same writer atomically enforces that the transition can only happen from the expected latest revision, using a caller supplied expected revision and a latest revision subquery in the UPDATE statement as a compare-and-swap guard. This binds vote initiation to release phase, revision state, policy, committee membership, check results, file storage, and task execution rather than to form fields alone.
 
 ### Trusted Publishing
 
diff --git a/atr/get/committees.py b/atr/get/committees.py
index a36ebc01..17c3a3bc 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -25,6 +25,7 @@ import sqlmodel
 import atr.blueprints.get as get
 import atr.db as db
 import atr.form as form
+import atr.htm as htm
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.post as post
@@ -93,6 +94,36 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
     for project in project_list:
         # Workaround for the usual loading problem
         project.committee = committee
+
+    is_standing = util.committee_is_standing(committee.key)
+    release_managers = sorted(committee.release_managers)
+    roster_action_forms: dict[str, htm.Element] = {}
+    if committee_member and (not is_standing):
+        action = util.as_url(post.committees.view, name=committee.key)
+        for uid in roster:
+            if uid in committee.committee_members:
+                # PMC members are release managers implicitly, so there is no action
+                continue
+            if uid in committee.release_managers:
+                model_cls: type[form.Form] = shared.committees.RemoveReleaseManagerForm
+                submit_label = "Remove"
+                submit_classes = "btn-outline-danger btn-sm"
+            elif uid in committee.committers:
+                model_cls = shared.committees.AddReleaseManagerForm
+                submit_label = "Designate"
+                submit_classes = "btn-outline-primary btn-sm"
+            else:
+                continue
+            roster_action_forms[uid] = await form.render(
+                model_cls=model_cls,
+                action=action,
+                form_classes=".d-inline-block.m-0",
+                submit_classes=submit_classes,
+                submit_label=submit_label,
+                empty=True,
+                defaults={"committee_key": committee.key, "asf_uid": uid},
+            )
+
     return await template.render(
         "committee-view.html",
         committee=committee,
@@ -105,6 +136,8 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
         now=datetime.datetime.now(datetime.UTC),
         email_from_key=util.email_from_uid,
         is_committee_member=committee_member,
+        release_managers=release_managers,
+        roster_action_forms=roster_action_forms,
         update_committee_keys_form=await form.render(
             model_cls=shared.keys.UpdateCommitteeKeysForm,
             action=util.as_url(post.keys.keys),
@@ -112,5 +145,5 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
             defaults={"committee_key": committee.key},
             empty=True,
         ),
-        is_standing=util.committee_is_standing(committee.key),
+        is_standing=is_standing,
     )
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 56d22a42..3aab8116 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -226,7 +226,7 @@ async def select(session: web.Committer, _project_select: Literal["project/selec
                     (test_mode and (p.committee.key == "test"))
                     or (session.uid in p.committee.committee_members)
                     or (session.uid in p.committee.committers)
-                    or (session.uid in p.committee.release_managers)
+                    or user.is_release_manager(p.committee, session.uid)
                 )
             ]
             user_projects.sort(key=lambda p: p.display_name)
@@ -1039,7 +1039,14 @@ def _render_pmc_card(project: sql.Project) -> htm.Element:
         committee_link = htm.a(href=util.as_url(committees.view, name=project.committee.key))[
             project.committee.display_name
         ]
-        card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")[committee_link]]
+        # release_managers_link = htm.a(
+        #     ".text-muted",
+        #     href=util.as_url(committees.view, name=project.committee.key) + "#release-managers",
+        # )["Release managers"]
+        card.div(".card-body")[
+            htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")[committee_link],
+            # htm.div(".small.text-muted")[release_managers_link],
+        ]
     else:
         card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")["No committee"]]
     return card.collect()
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 8889aad4..cfc45260 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -77,20 +77,12 @@ async def category_and_release(
             raise ValueError("Release has no committee")
 
         latest_vote_task = await interaction.release_current_vote_task(release, data)
-        vote_initiator_uid: str | None = None
-        if latest_vote_task is not None:
-            vote_initiator_uid = latest_vote_task.task_args.get("initiator_id")
 
     if session is None:
         return UserCategory.UNAUTHENTICATED, release, latest_vote_task
 
     is_pmc_member = user.is_committee_member(release.committee, session.uid)
-    is_designated_release_manager = (session.uid in release.committee.release_managers) and (
-        session.uid in release.committee.committers
-    )
-    is_release_manager = is_designated_release_manager or (
-        (vote_initiator_uid is not None) and (session.uid == vote_initiator_uid)
-    )
+    is_release_manager = user.is_release_manager(release.committee, session.uid)
 
     if is_pmc_member and is_release_manager:
         user_category = UserCategory.PMC_MEMBER_RM
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index 478d3356..3a943266 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -18,6 +18,7 @@
 from typing import Final, Literal
 
 import atr.post.announce as announce
+import atr.post.committees as committees
 import atr.post.compose as compose
 import atr.post.distribution as distribution
 import atr.post.draft as draft
@@ -42,6 +43,7 @@ ROUTES_MODULE: Final[Literal[True]] = True
 
 __all__ = [
     "announce",
+    "committees",
     "compose",
     "distribution",
     "draft",
diff --git a/atr/post/committees.py b/atr/post/committees.py
new file mode 100644
index 00000000..0df359bc
--- /dev/null
+++ b/atr/post/committees.py
@@ -0,0 +1,79 @@
+# 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 atr.blueprints.post as post
+import atr.get as get
+import atr.models.safe as safe
+import atr.shared as shared
+import atr.storage as storage
+import atr.web as web
+
+
+@post.typed
+async def view(
+    session: web.Committer,
+    _committees: Literal["committees"],
+    name: safe.CommitteeKey,
+    committees_form: shared.committees.CommitteesForm,
+) -> web.WerkzeugResponse:
+    """
+    URL: /committees/<name>
+    Handle the release manager designation forms on the committee page.
+    """
+    if committees_form.committee_key != str(name):
+        raise RuntimeError("Committee key mismatch")
+
+    match committees_form:
+        case shared.committees.AddReleaseManagerForm() as add_form:
+            return await _release_manager_add(session, name, add_form)
+
+        case shared.committees.RemoveReleaseManagerForm() as remove_form:
+            return await _release_manager_remove(session, name, remove_form)
+
+
+async def _release_manager_add(
+    session: web.Committer, name: safe.CommitteeKey, add_form: shared.committees.AddReleaseManagerForm
+) -> web.WerkzeugResponse:
+    try:
+        async with storage.write_as_committee_member(str(name), session) as wacm:
+            added = await wacm.committee.release_manager_add(add_form.asf_uid)
+    except storage.AccessError as e:
+        return await session.redirect(get.committees.view, error=str(e), name=str(name))
+    asf_uid = add_form.asf_uid.strip().lower()
+    if added:
+        message = f"Designated {asf_uid} as a release manager."
+    else:
+        message = f"{asf_uid} is already a release manager."
+    return await session.redirect(get.committees.view, success=message, name=str(name))
+
+
+async def _release_manager_remove(
+    session: web.Committer, name: safe.CommitteeKey, remove_form: shared.committees.RemoveReleaseManagerForm
+) -> web.WerkzeugResponse:
+    try:
+        async with storage.write_as_committee_member(str(name), session) as wacm:
+            removed = await wacm.committee.release_manager_remove(remove_form.asf_uid)
+    except storage.AccessError as e:
+        return await session.redirect(get.committees.view, error=str(e), name=str(name))
+    asf_uid = remove_form.asf_uid.strip().lower()
+    if removed:
+        return await session.redirect(
+            get.committees.view, success=f"Removed {asf_uid} as a release manager.", name=str(name)
+        )
+    return await session.redirect(get.committees.view, error=f"{asf_uid} is not a release manager.", name=str(name))
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 68e836ba..98f70daf 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -114,7 +114,7 @@ async def start_selected(
                     project_key=str(project_key),
                     version_key=str(version_key),
                 )
-            case (release, committee):
+            case (release, _committee):
                 pass
 
         if start_vote_form.rendered_revision != release.safe_latest_revision_number:
@@ -134,8 +134,8 @@ async def start_selected(
                 util.concern_acknowledgement_error(missing),
             )
 
-        async with storage.write_as_committee_participant(committee.key, session) as wacp:
-            error = await wacp.release.promote_to_candidate(
+        async with storage.write_as_project_release_manager(project_key, session) as warm:
+            error = await warm.release.promote_to_candidate(
                 release.safe_key,
                 start_vote_form.rendered_revision,
                 allowed_vote_modes=frozenset({sql.VoteMode.MANUAL}),
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 6cb664c6..1499ff03 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -200,8 +200,8 @@ async def selected(  # noqa: C901
         subject, _ = await construct.start_vote_subject_and_body(subject_template, "", options)
 
         try:
-            async with storage.write_as_committee_participant(committee.key) as wacp:
-                _task = await wacp.vote.start(
+            async with storage.write_as_project_release_manager(project_key) as warm:
+                _task = await warm.vote.start(
                     start_voting_form.email_to,
                     project_key,
                     version_key,
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 94155539..3d8a269c 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -18,6 +18,7 @@
 from typing import Final
 
 import atr.shared.announce as announce
+import atr.shared.committees as committees
 import atr.shared.compose as compose
 import atr.shared.distribution as distribution
 import atr.shared.draft as draft
@@ -66,6 +67,7 @@ algorithms: Final[dict[int, str]] = {
 __all__ = [
     "algorithms",
     "announce",
+    "committees",
     "compose",
     "distribution",
     "draft",
diff --git a/atr/shared/committees.py b/atr/shared/committees.py
new file mode 100644
index 00000000..a9b26b2c
--- /dev/null
+++ b/atr/shared/committees.py
@@ -0,0 +1,41 @@
+# 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 Annotated, Literal
+
+import atr.form as form
+
+type ADD_RELEASE_MANAGER = Literal["add_release_manager"]
+type REMOVE_RELEASE_MANAGER = Literal["remove_release_manager"]
+
+
+class AddReleaseManagerForm(form.Form):
+    variant: ADD_RELEASE_MANAGER = form.value(ADD_RELEASE_MANAGER)
+    committee_key: str = form.label("Committee name", widget=form.Widget.HIDDEN)
+    asf_uid: str = form.label("ASF UID", widget=form.Widget.HIDDEN)
+
+
+class RemoveReleaseManagerForm(form.Form):
+    variant: REMOVE_RELEASE_MANAGER = form.value(REMOVE_RELEASE_MANAGER)
+    committee_key: str = form.label("Committee name", widget=form.Widget.HIDDEN)
+    asf_uid: str = form.label("ASF UID", widget=form.Widget.HIDDEN)
+
+
+type CommitteesForm = Annotated[
+    AddReleaseManagerForm | RemoveReleaseManagerForm,
+    form.DISCRIMINATOR,
+]
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index ab78fb6b..0a40f9db 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -206,6 +206,7 @@ class WriteAsReleaseManager(WriteAsCommitteeParticipant):
         self.distributions = writers.distributions.ReleaseManager(write, self, data, committee_key)
         self.policy = writers.policy.ReleaseManager(write, self, data, committee_key)
         self.project = writers.project.ReleaseManager(write, self, data, committee_key)
+        self.release = writers.release.ReleaseManager(write, self, data, committee_key)
         self.vote = writers.vote.ReleaseManager(write, self, data, committee_key)
         self.workflowstatus = writers.workflowstatus.ReleaseManager(write, self, data, committee_key)
 
@@ -218,6 +219,7 @@ class WriteAsCommitteeMember(WriteAsReleaseManager):
         self.announce = writers.announce.CommitteeMember(write, self, data, committee_key)
         self.cache = writers.cache.CommitteeMember(write, self, data, committee_key)
         self.checks = writers.checks.CommitteeMember(write, self, data, committee_key)
+        self.committee = writers.committee.CommitteeMember(write, self, data, committee_key)
         self.distributions = writers.distributions.CommitteeMember(write, self, data, committee_key)
         self.keys = writers.keys.CommitteeMember(write, self, data, committee_key)
         self.mail = writers.mail.CommitteeMember(write, self, data, committee_key)
@@ -472,9 +474,7 @@ class Write:
         if asf_uid is None:
             return outcome.Error(AccessError("Not authorized", status=403))
         is_pmc_member = self.__authorisation.is_member_of(project.committee.key)
-        is_designated_release_manager = (asf_uid in project.committee.release_managers) and (
-            asf_uid in project.committee.committers
-        )
+        is_designated_release_manager = user.is_release_manager(project.committee, asf_uid)
         if (not is_pmc_member) and (not is_designated_release_manager):
             return outcome.Error(AccessError(f"Not a release manager for {project.committee.key}", status=403))
         try:
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 5a8b34e6..c5b96163 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import atr.storage.writers.announce as announce
 import atr.storage.writers.cache as cache
 import atr.storage.writers.checks as checks
+import atr.storage.writers.committee as committee
 import atr.storage.writers.distributions as distributions
 import atr.storage.writers.keys as keys
 import atr.storage.writers.mail as mail
@@ -39,6 +40,7 @@ __all__ = [
     "announce",
     "cache",
     "checks",
+    "committee",
     "distributions",
     "keys",
     "mail",
diff --git a/atr/storage/writers/committee.py b/atr/storage/writers/committee.py
new file mode 100644
index 00000000..2bc3319c
--- /dev/null
+++ b/atr/storage/writers/committee.py
@@ -0,0 +1,166 @@
+# 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.
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+import atr.db as db
+import atr.models.sql as sql
+import atr.storage as storage
+
+
+class GeneralPublic:
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsGeneralPublic,
+        data: db.Session,
+    ):
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        self.__asf_uid = write.authorisation.asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+    def __init__(self, write: storage.Write, write_as: storage.WriteAsFoundationCommitter, data: db.Session):
+        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.__asf_uid = asf_uid
+
+
+class CommitteeParticipant(FoundationCommitter):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeParticipant,
+        data: db.Session,
+        committee_key: str,
+    ):
+        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.__asf_uid = asf_uid
+        self.__committee_key = committee_key
+
+
+class ReleaseManager(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsReleaseManager,
+        data: db.Session,
+        committee_key: str,
+    ):
+        super().__init__(write, write_as, data, committee_key)
+        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.__asf_uid = asf_uid
+        self.__committee_key = committee_key
+
+
+class CommitteeMember(ReleaseManager):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeMember,
+        data: db.Session,
+        committee_key: str,
+    ):
+        super().__init__(write, write_as, data, committee_key)
+        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.__asf_uid = asf_uid
+        self.__committee_key = committee_key
+
+    async def release_manager_add(self, asf_uid: str) -> bool:
+        asf_uid = asf_uid.strip().lower()
+        if not asf_uid:
+            raise storage.AccessError("An ASF UID is required", status=400)
+        await self.__data.begin_immediate()
+        try:
+            committee = await self.__load_committee()
+            if asf_uid in committee.committee_members:
+                raise storage.AccessError(
+                    f"{asf_uid} is a member of the {self.__committee_key} PMC,"
+                    " and is therefore already a release manager",
+                    status=400,
+                )
+            if asf_uid not in committee.committers:
+                raise storage.AccessError(
+                    f"{asf_uid} is not a committer of the {self.__committee_key} committee",
+                    status=400,
+                )
+            if asf_uid in committee.release_managers:
+                await self.__data.rollback()
+                return False
+            # JSON columns do not track in place mutation, so assign a new list
+            committee.release_managers = sorted({*committee.release_managers, asf_uid})
+            committee.mark_updated(by=self.__asf_uid, update_type=sql.UpdateType.MANUAL)
+            await self.__data.commit()
+        except Exception:
+            await self.__data.rollback()
+            raise
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            committee_key=self.__committee_key,
+            release_manager=asf_uid,
+        )
+        return True
+
+    async def release_manager_remove(self, asf_uid: str) -> bool:
+        asf_uid = asf_uid.strip().lower()
+        await self.__data.begin_immediate()
+        try:
+            committee = await self.__load_committee()
+            if asf_uid not in committee.release_managers:
+                await self.__data.rollback()
+                return False
+            committee.release_managers = [uid for uid in committee.release_managers if uid != asf_uid]
+            committee.mark_updated(by=self.__asf_uid, update_type=sql.UpdateType.MANUAL)
+            await self.__data.commit()
+        except Exception:
+            await self.__data.rollback()
+            raise
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            committee_key=self.__committee_key,
+            release_manager=asf_uid,
+        )
+        return True
+
+    async def __load_committee(self) -> sql.Committee:
+        return await self.__data.committee(key=self.__committee_key).demand(
+            storage.AccessError(f"Committee not found: {self.__committee_key}", status=404)
+        )
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index e4c166a0..44b6f2f2 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -706,41 +706,6 @@ class CommitteeParticipant(FoundationCommitter):
             message=f"Published to SVN as r{revision}",
         )
 
-    async def promote_to_candidate(
-        self,
-        release_key: safe.ReleaseKey,
-        expected_revision: safe.RevisionNumber,
-        *,
-        allowed_vote_modes: frozenset[sql.VoteMode],
-        acknowledged_concerns: frozenset[str] = frozenset(),
-    ) -> str | None:
-        """Promote a release candidate draft to a new phase."""
-        try:
-            await self.__data.begin_immediate()
-            release, vote_seq, vote_mode, revision_number = await self._start_vote_no_commit(
-                release_key,
-                expected_revision,
-                allowed_vote_modes=allowed_vote_modes,
-                promote=True,
-                acknowledged_concerns=acknowledged_concerns,
-            )
-            await self.__data.commit()
-        except storage.AccessError as e:
-            await self.__data.rollback()
-            return str(e)
-        except Exception:
-            await self.__data.rollback()
-            raise
-
-        self.__write_as.append_to_audit_log(
-            asf_uid=self.__asf_uid,
-            release_key=release.key,
-            revision_number=str(revision_number),
-            vote_seq=vote_seq,
-            vote_mode=vote_mode.value,
-        )
-        return None
-
     async def _start_vote_no_commit(  # noqa: C901
         self,
         release_key: safe.ReleaseKey,
@@ -1277,7 +1242,61 @@ class CommitteeParticipant(FoundationCommitter):
         return result.scalar_one()
 
 
-class CommitteeMember(CommitteeParticipant):
+class ReleaseManager(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsReleaseManager,
+        data: db.Session,
+        committee_key: str,
+    ) -> None:
+        super().__init__(write, write_as, data, committee_key)
+        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.__asf_uid = asf_uid
+        self.__committee_key = committee_key
+
+    async def promote_to_candidate(
+        self,
+        release_key: safe.ReleaseKey,
+        expected_revision: safe.RevisionNumber,
+        *,
+        allowed_vote_modes: frozenset[sql.VoteMode],
+        acknowledged_concerns: frozenset[str] = frozenset(),
+    ) -> str | None:
+        """Promote a release candidate draft to a new phase."""
+        try:
+            await self.__data.begin_immediate()
+            release, vote_seq, vote_mode, revision_number = await self._start_vote_no_commit(
+                release_key,
+                expected_revision,
+                allowed_vote_modes=allowed_vote_modes,
+                promote=True,
+                acknowledged_concerns=acknowledged_concerns,
+            )
+            await self.__data.commit()
+        except storage.AccessError as e:
+            await self.__data.rollback()
+            return str(e)
+        except Exception:
+            await self.__data.rollback()
+            raise
+
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            release_key=release.key,
+            revision_number=str(revision_number),
+            vote_seq=vote_seq,
+            vote_mode=vote_mode.value,
+        )
+        return None
+
+
+class CommitteeMember(ReleaseManager):
     def __init__(
         self,
         write: storage.Write,
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 3e0c173a..be26f365 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -292,6 +292,25 @@ class CommitteeParticipant(FoundationCommitter):
 
         return [email_to], ""
 
+
+class ReleaseManager(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsReleaseManager,
+        data: db.Session,
+        committee_key: str,
+    ):
+        super().__init__(write, write_as, data, committee_key)
+        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.__asf_uid = asf_uid
+        self.__committee_key = committee_key
+
     async def start(  # noqa: C901
         self,
         email_to: str,
@@ -365,13 +384,9 @@ class CommitteeParticipant(FoundationCommitter):
                     status=403,
                 )
             if automatic_resolve_when_finished:
-                is_pmc_member = self.__asf_uid in committee.committee_members
-                is_designated_release_manager = (self.__asf_uid in committee.release_managers) and (
-                    self.__asf_uid in committee.committers
-                )
+                is_pmc_member = user.is_committee_member(committee, self.__asf_uid)
+                is_designated_release_manager = user.is_release_manager(committee, self.__asf_uid)
                 if (not is_pmc_member) and (not is_designated_release_manager):
-                    # TODO: Maybe we should modularise all of this?
-                    # Then we could use the relevant permissions class
                     raise storage.AccessError(
                         "Automatic vote resolution requires a committee member or release manager initiator",
                         status=403,
@@ -471,25 +486,6 @@ class CommitteeParticipant(FoundationCommitter):
             return None
         return project.committee
 
-
-class ReleaseManager(CommitteeParticipant):
-    def __init__(
-        self,
-        write: storage.Write,
-        write_as: storage.WriteAsReleaseManager,
-        data: db.Session,
-        committee_key: str,
-    ):
-        super().__init__(write, write_as, data, committee_key)
-        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.__asf_uid = asf_uid
-        self.__committee_key = committee_key
-
     async def resolve(  # noqa: C901
         self,
         project_key: safe.ProjectKey,
diff --git a/atr/templates/committee-view.html b/atr/templates/committee-view.html
index 91dcec19..950a387f 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -81,11 +81,18 @@
     </div>
   </div>
 
-  <div class="card mb-4">
+  <div class="card mb-4" id="release-managers">
     <div class="card-header bg-light">
       <h3 class="mb-0">Roster</h3>
     </div>
     <div class="card-body">
+      {% if not is_standing %}
+        <p>
+          All PMC members are release managers. The PMC can also designate committers as release
+          managers, giving them the same release permissions as PMC members for all
+          {{ committee.display_name }} projects, but not a binding vote.
+        </p>
+      {% endif %}
       {% if roster %}
         <div class="table-responsive">
           <table class="table border table-striped table-sm">
@@ -94,6 +101,12 @@
                 <th class="px-2" scope="col">ASF UID</th>
                 <th class="px-2" scope="col">Name</th>
                 <th class="px-2 text-center" scope="col">PMC member</th>
+                {% if not is_standing %}
+                  <th class="px-2 text-center" scope="col">Release manager</th>
+                  {% if is_committee_member %}
+                    <th class="px-2" scope="col">Actions</th>
+                  {% endif %}
+                {% endif %}
               </tr>
             </thead>
             <tbody>
@@ -108,6 +121,20 @@
                       <i class="bi bi-dash text-muted fs-5" aria-label="Committer only"></i>
                     {% endif %}
                   </td>
+                  {% if not is_standing %}
+                    <td class="px-2 text-center">
+                      {% if uid in committee.committee_members %}
+                        <i class="bi bi-check-lg text-success fs-5" aria-label="Release manager as a PMC member"></i>
+                      {% elif uid in release_managers %}
+                        <i class="bi bi-check-lg text-success fs-5" aria-label="Designated release manager"></i>
+                      {% else %}
+                        <i class="bi bi-dash text-muted fs-5" aria-label="Not a release manager"></i>
+                      {% endif %}
+                    </td>
+                    {% if is_committee_member %}
+                      <td class="px-2">{{ roster_action_forms.get(uid) or "" }}</td>
+                    {% endif %}
+                  {% endif %}
                 </tr>
               {% endfor %}
             </tbody>
diff --git a/atr/validate.py b/atr/validate.py
index 5a44c564..9b3c419a 100644
--- a/atr/validate.py
+++ b/atr/validate.py
@@ -181,13 +181,21 @@ def committee_members(c: sql.Committee) -> Divergences:
 
 @committee_components("Committee.release_managers")
 def committee_release_managers(c: sql.Committee) -> Divergences:
-    """Check that every release_managers entry looks like an ASF UID."""
+    """Check that every release_managers entry looks like an ASF UID and is a non-member committer."""
 
     def okay(uid: str) -> bool:
         return bool(_ASF_UID_PATTERN.match(uid))
 
+    def committer(uid: str) -> bool:
+        return uid in c.committers
+
+    def non_member(uid: str) -> bool:
+        return uid not in c.committee_members
+
     for uid in c.release_managers:
         yield from divergences_predicate(okay, "value to be an ASF UID", uid)
+        yield from divergences_predicate(committer, "release manager to be a committer", uid)
+        yield from divergences_predicate(non_member, "release manager to not be a PMC member", uid)
 
 
 def committees(cs: Iterable[sql.Committee]) -> AnnotatedDivergences:
diff --git a/tests/unit/test_archived_project_writer_guards.py b/tests/unit/test_archived_project_writer_guards.py
index 7b8d2104..cc91a755 100644
--- a/tests/unit/test_archived_project_writer_guards.py
+++ b/tests/unit/test_archived_project_writer_guards.py
@@ -430,10 +430,13 @@ async def test_release_promote_to_candidate_blocks_retired() -> None:
     data.release = mock.MagicMock(return_value=_query_returning(_retired_release()))
     data.begin_immediate = mock.AsyncMock()
     data.rollback = mock.AsyncMock()
-    writer = object.__new__(release.CommitteeParticipant)
+    writer = object.__new__(release.ReleaseManager)
     writer._CommitteeParticipant__data = data
     writer._CommitteeParticipant__asf_uid = "tester"
     writer._CommitteeParticipant__committee_key = "project"
+    writer._ReleaseManager__data = data
+    writer._ReleaseManager__asf_uid = "tester"
+    writer._ReleaseManager__committee_key = "project"
 
     # promote_to_candidate catches AccessError internally and returns it as a string
     result = await writer.promote_to_candidate(
diff --git a/tests/unit/test_committee_writer.py b/tests/unit/test_committee_writer.py
new file mode 100644
index 00000000..d8c347ea
--- /dev/null
+++ b/tests/unit/test_committee_writer.py
@@ -0,0 +1,182 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.writers.committee as committee
+
+
+class Query:
+    def __init__(self, value: object) -> None:
+        self.value = value
+
+    async def demand(self, error: Exception) -> object:
+        if self.value is None:
+            raise error
+        return self.value
+
+
+def test_committee_writer_is_member_only() -> None:
+    write = mock.MagicMock()
+    write.authorisation.asf_uid = "alice"
+    data = mock.MagicMock()
+    warm = storage.WriteAsReleaseManager(write, data, "alpha")
+    wacm = storage.WriteAsCommitteeMember(write, data, "alpha")
+
+    assert not hasattr(warm, "committee")
+    assert hasattr(wacm.committee, "release_manager_add")
+    assert hasattr(wacm.committee, "release_manager_remove")
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_designates_committer() -> None:
+    row = _committee_row()
+    writer, data, write_as = _writer_with_mocks(row)
+
+    added = await writer.release_manager_add("carol")
+
+    assert added is True
+    assert row.release_managers == ["carol"]
+    assert row.updated_by == "chair"
+    data.commit.assert_awaited_once()
+    data.rollback.assert_not_awaited()
+    write_as.append_to_audit_log.assert_called_once()
+    assert write_as.append_to_audit_log.call_args.kwargs["release_manager"] == "carol"
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_is_idempotent() -> None:
+    row = _committee_row(release_managers=["carol"])
+    writer, data, write_as = _writer_with_mocks(row)
+
+    added = await writer.release_manager_add("carol")
+
+    assert added is False
+    assert row.release_managers == ["carol"]
+    data.commit.assert_not_awaited()
+    data.rollback.assert_awaited_once()
+    write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_normalises_uid() -> None:
+    row = _committee_row()
+    writer, _data, _write_as = _writer_with_mocks(row)
+
+    added = await writer.release_manager_add("  Carol ")
+
+    assert added is True
+    assert row.release_managers == ["carol"]
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_rejects_empty_uid() -> None:
+    writer, data, _write_as = _writer_with_mocks(_committee_row())
+
+    with pytest.raises(storage.AccessError, match="ASF UID is required"):
+        await writer.release_manager_add("  ")
+
+    data.begin_immediate.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_rejects_non_committer() -> None:
+    row = _committee_row()
+    writer, data, write_as = _writer_with_mocks(row)
+
+    with pytest.raises(storage.AccessError, match="not a committer"):
+        await writer.release_manager_add("mallory")
+
+    assert row.release_managers == []
+    data.commit.assert_not_awaited()
+    data.rollback.assert_awaited_once()
+    write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_manager_add_rejects_pmc_member() -> None:
+    row = _committee_row()
+    writer, data, write_as = _writer_with_mocks(row)
+
+    with pytest.raises(storage.AccessError, match="already a release manager"):
+        await writer.release_manager_add("chair")
+
+    assert row.release_managers == []
+    data.commit.assert_not_awaited()
+    data.rollback.assert_awaited_once()
+    write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_manager_remove_absent_uid_is_noop() -> None:
+    row = _committee_row(release_managers=["bob"])
+    writer, data, write_as = _writer_with_mocks(row)
+
+    removed = await writer.release_manager_remove("carol")
+
+    assert removed is False
+    assert row.release_managers == ["bob"]
+    data.commit.assert_not_awaited()
+    data.rollback.assert_awaited_once()
+    write_as.append_to_audit_log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_release_manager_remove_removes_designation() -> None:
+    row = _committee_row(release_managers=["bob", "carol"])
+    writer, data, write_as = _writer_with_mocks(row)
+
+    removed = await writer.release_manager_remove("carol")
+
+    assert removed is True
+    assert row.release_managers == ["bob"]
+    data.commit.assert_awaited_once()
+    write_as.append_to_audit_log.assert_called_once()
+
+
+def _committee_row(
+    committers: list[str] | None = None,
+    release_managers: list[str] | None = None,
+) -> sql.Committee:
+    return sql.Committee(
+        key="alpha",
+        committee_members=["chair"],
+        committers=committers if (committers is not None) else ["chair", "carol"],
+        release_managers=release_managers if (release_managers is not None) else [],
+    )
+
+
+def _writer_with_mocks(
+    committee_row: sql.Committee | None,
+) -> tuple[committee.CommitteeMember, mock.MagicMock, SimpleNamespace]:
+    data = mock.MagicMock()
+    data.committee = mock.MagicMock(return_value=Query(committee_row))
+    data.begin_immediate = mock.AsyncMock()
+    data.commit = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+    write_as = SimpleNamespace(append_to_audit_log=mock.MagicMock())
+    writer = object.__new__(committee.CommitteeMember)
+    writer._CommitteeMember__data = data
+    writer._CommitteeMember__write_as = write_as
+    writer._CommitteeMember__asf_uid = "chair"
+    writer._CommitteeMember__committee_key = "alpha"
+    return writer, data, write_as
diff --git a/tests/unit/test_release_manager_purge.py b/tests/unit/test_release_manager_purge.py
new file mode 100644
index 00000000..5a062ab9
--- /dev/null
+++ b/tests/unit/test_release_manager_purge.py
@@ -0,0 +1,52 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atr.datasources.apache as apache
+import atr.models.sql as sql
+
+
+def test_purge_handles_empty_release_managers() -> None:
+    committee = _committee(members=["chair"], release_managers=[])
+
+    apache._purge_member_release_managers(committee)
+
+    assert committee.release_managers == []
+
+
+def test_purge_keeps_non_member_release_managers() -> None:
+    committee = _committee(members=["chair"], release_managers=["bob", "carol"])
+
+    apache._purge_member_release_managers(committee)
+
+    assert committee.release_managers == ["bob", "carol"]
+
+
+def test_purge_removes_members_from_release_managers() -> None:
+    committee = _committee(members=["chair", "carol"], release_managers=["bob", "carol"])
+
+    apache._purge_member_release_managers(committee)
+
+    assert committee.release_managers == ["bob"]
+
+
+def _committee(members: list[str], release_managers: list[str]) -> sql.Committee:
+    return sql.Committee(
+        key="alpha",
+        committee_members=members,
+        committers=members + release_managers,
+        release_managers=release_managers,
+    )
diff --git a/tests/unit/test_release_manager_writer_surface.py b/tests/unit/test_release_manager_writer_surface.py
index 2f9b783d..d6ddef97 100644
--- a/tests/unit/test_release_manager_writer_surface.py
+++ b/tests/unit/test_release_manager_writer_surface.py
@@ -208,9 +208,12 @@ def test_release_manager_surface_exposes_only_approved_committee_member_writers(
     assert hasattr(warm.distributions, "record_from_data")
     assert hasattr(warm.policy, "edit_vote")
     assert hasattr(warm.project, "edit_metadata")
+    assert hasattr(warm.release, "promote_to_candidate")
     assert hasattr(warm.vote, "resolve")
+    assert hasattr(warm.vote, "start")
     assert hasattr(warm.workflowstatus, "add_workflow_status")
 
+    assert not hasattr(warm, "committee")
     assert not hasattr(warm.checks, "ignore_add")
     assert not hasattr(warm.project, "archive")
     assert not hasattr(warm.project, "category_add")
diff --git a/tests/unit/test_vote_end_notify.py b/tests/unit/test_vote_end_notify.py
index fca5d508..e1f579e6 100644
--- a/tests/unit/test_vote_end_notify.py
+++ b/tests/unit/test_vote_end_notify.py
@@ -536,11 +536,11 @@ async def test_writer_start_rejects_notify_outside_trusted_mode() -> None:
         ),
     )
     write_as = SimpleNamespace(release=release_writer, append_to_audit_log=mock.MagicMock())
-    writer = object.__new__(vote_writer.CommitteeParticipant)
-    writer._CommitteeParticipant__data = data
-    writer._CommitteeParticipant__write_as = write_as
-    writer._CommitteeParticipant__asf_uid = "chair"
-    writer._CommitteeParticipant__committee_key = "project"
+    writer = object.__new__(vote_writer.ReleaseManager)
+    writer._ReleaseManager__data = data
+    writer._ReleaseManager__write_as = write_as
+    writer._ReleaseManager__asf_uid = "chair"
+    writer._ReleaseManager__committee_key = "project"
 
     with pytest.raises(storage.AccessError, match="Trusted Vote mode"):
         await writer.start(
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index 57b4fd39..873d8d0d 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -165,7 +165,7 @@ async def test_start_email_vote_sets_vote_seq_on_task() -> None:
         ),
     )
     write_as = SimpleNamespace(release=release_writer, append_to_audit_log=mock.MagicMock())
-    writer = _participant_writer_with_mocks(data, write_as)
+    writer = _release_manager_writer_with_mocks(data, write_as)
 
     task = await writer.start(
         "dev@project.apache.org",
@@ -324,15 +324,6 @@ def _make_release(
     )
 
 
-def _participant_writer_with_mocks(data: mock.MagicMock, write_as: SimpleNamespace) -> vote.CommitteeParticipant:
-    writer = object.__new__(vote.CommitteeParticipant)
-    writer._CommitteeParticipant__data = data
-    writer._CommitteeParticipant__write_as = write_as
-    writer._CommitteeParticipant__asf_uid = "chair"
-    writer._CommitteeParticipant__committee_key = "project"
-    return writer
-
-
 def _patch_db_and_interaction(monkeypatch: pytest.MonkeyPatch, release: SimpleNamespace) -> None:
     query = mock.AsyncMock()
     query.demand = mock.AsyncMock(return_value=release)
@@ -360,6 +351,15 @@ def _patch_storage_write(monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.setattr("atr.storage.write", mock_storage_write)
 
 
+def _release_manager_writer_with_mocks(data: mock.MagicMock, write_as: SimpleNamespace) -> vote.ReleaseManager:
+    writer = object.__new__(vote.ReleaseManager)
+    writer._ReleaseManager__data = data
+    writer._ReleaseManager__write_as = write_as
+    writer._ReleaseManager__asf_uid = "chair"
+    writer._ReleaseManager__committee_key = "project"
+    return writer
+
+
 def _writer_with_data(data: mock.MagicMock) -> vote.CommitteeMember:
     writer = object.__new__(vote.CommitteeMember)
     writer._ReleaseManager__data = data


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