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

(tooling-trusted-releases) branch arm updated: #1246 - allow multiple emails for announce, BCC private for vote resolution

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

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


The following commit(s) were added to refs/heads/arm by this push:
     new 13d3c839 #1246 - allow multiple emails for announce, BCC private for vote resolution
13d3c839 is described below

commit 13d3c8395378ec7a94cc6042278be58533d62c9f
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu May 21 10:51:40 2026 +0100

    #1246 - allow multiple emails for announce, BCC private for vote resolution
---
 atr/get/announce.py             | 182 +++++++++++++++++++++++-----------------
 atr/get/resolve.py              |  21 +++++
 atr/post/announce.py            |  12 +--
 atr/post/resolve.py             |   8 ++
 atr/shared/resolve.py           |   9 +-
 atr/storage/writers/announce.py |   2 +-
 atr/storage/writers/vote.py     |  16 +++-
 atr/tabulate.py                 |   4 +
 atr/util.py                     |  21 +++--
 9 files changed, 180 insertions(+), 95 deletions(-)

diff --git a/atr/get/announce.py b/atr/get/announce.py
index eec0a637..5a7d80e0 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -99,7 +99,7 @@ async def selected(
         revision_number=release.unwrap_revision_number,
     )
 
-    permitted_recipients = util.permitted_announce_recipients(session.uid)
+    permitted_recipients = util.permitted_announce_recipients(session.uid, committee_key=util.unwrap(committee.key))
 
     content = await _render_page(
         release=release,
@@ -135,6 +135,94 @@ async def _get_page_data(
     return release
 
 
+def _missing_distributions_message(release: sql.Release) -> str:
+    policy = release.release_policy or release.project.release_policy
+    if not (policy and policy.file_tag_mappings):
+        return ""
+    published = [d.platform.value.gh_slug for d in release.distributions if (not d.staging) and (not d.pending)]
+    missing = [tag for tag in policy.file_tag_mappings if tag not in published]
+    if not missing:
+        return ""
+    return (
+        f"This release cannot be announced until the following distributions have been recorded: {', '.join(missing)}"
+    )
+
+
+def _recipients_documentation() -> str | None:
+    if config.get().ATR_STATUS != "ALPHA":
+        return None
+    return (
+        "Note: The options to send to the user-tests mailing list and yourself are provided for"
+        " testing purposes only, and will not be available in the finished version of ATR."
+    )
+
+
+async def _render_announce_form(
+    page: htm.Block,
+    release: sql.Release,
+    *,
+    permitted_recipients: list[str],
+    subject_template_hash: str,
+    default_subject: str,
+    default_body: str,
+    default_download_path_value: str,
+    download_path_description: str,
+    preview_url: str,
+) -> None:
+    custom_subject_widget = _render_subject_field(default_subject, release.project.key)
+    custom_body_widget = _render_body_field(default_body, release.project.key)
+    download_path_widget = _render_download_path_field(default_download_path_value, download_path_description)
+    default_to = permitted_recipients[0] if permitted_recipients else None
+    to_radios = htm.div[
+        render.html_recipients_to_radios(
+            permitted_recipients,
+            default_to=default_to,
+            documentation=_recipients_documentation(),
+        ),
+        htpy.details(".mt-2")[
+            htpy.summary["Select CC and BCC recipients"],
+            render.html_recipients_cc_bcc_table(permitted_recipients),
+        ],
+    ]
+
+    prior_release_version = ""
+    if release.archive_prior_release and release.project.policy_auto_archive_prior_release:
+        prior = await interaction.prior_release_for_archive(release.project, release.version)
+        if prior is not None:
+            prior_release_version = prior.version
+
+    defaults_dict = {
+        "revision_number": release.unwrap_revision_number,
+        "subject_template_hash": subject_template_hash,
+        "body": default_body,
+        "auto_archive": bool(release.archive_prior_release),
+        "auto_archive_release": prior_release_version or "",
+    }
+
+    skip = ["email_cc", "email_bcc"]
+    if not prior_release_version:
+        # Prior release version will only be set if the options for archival are True *and* we found a release
+        skip.extend(["auto_archive", "auto_archive_release"])
+    await form.render_block(
+        page,
+        model_cls=shared.announce.AnnounceForm,
+        action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version),
+        submit_label="Publish & announce",
+        defaults=defaults_dict,
+        custom={
+            "email_to": to_radios,
+            "subject": custom_subject_widget,
+            "body": custom_body_widget,
+            "download_path_suffix": download_path_widget,
+        },
+        skip=skip,
+        form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
+        border=True,
+        wider_widgets=True,
+    )
+    page.append(htpy.div("#announce-body-config.d-none", data_preview_url=preview_url))
+
+
 def _render_body_field(default_body: str, project_key: str) -> htm.Element:
     """Render the body textarea with a link to edit the template."""
     textarea = htpy.textarea(
@@ -206,85 +294,23 @@ async def _render_page(
     if banner := render.archived_project_banner(release.project, "Release actions are disabled."):
         page.append(banner)
 
-    announce_msg = ""
-    policy = release.release_policy or release.project.release_policy
-    if policy and policy.file_tag_mappings:
-        missing = []
-        tags = policy.file_tag_mappings.keys()
-        distributions = [d.platform.value.gh_slug for d in release.distributions if (not d.staging) and (not d.pending)]
-        for tag in tags:
-            if tag not in distributions:
-                missing.append(tag)
-        if missing:
-            announce_msg = f"This release cannot be announced until the following distributions have been recorded: {
-                ', '.join(missing)
-            }"
-
-    if not announce_msg:
-        page.p["This form will send an announcement to the selected recipients."]
-
-        custom_subject_widget = _render_subject_field(default_subject, release.project.key)
-        custom_body_widget = _render_body_field(default_body, release.project.key)
-        default_to = permitted_recipients[0] if permitted_recipients else None
-        to_radios = htm.div[
-            render.html_recipients_to_radios(
-                permitted_recipients,
-                default_to=default_to,
-                documentation=(
-                    "Note: The options to send to the user-tests "
-                    "mailing list and yourself are provided for "
-                    "testing purposes only, and will not be "
-                    "available in the finished version of ATR."
-                ),
-            ),
-            htpy.details(".mt-2")[
-                htpy.summary["Select CC and BCC recipients"],
-                render.html_recipients_cc_bcc_table(permitted_recipients),
-            ],
-        ]
-
-        # Custom widget for download_path_suffix with custom documentation
-        download_path_widget = _render_download_path_field(default_download_path_value, download_path_description)
-
-        prior_release_version = ""
-        if release.archive_prior_release and release.project.policy_auto_archive_prior_release:
-            prior = await interaction.prior_release_for_archive(release.project, release.version)
-            if prior is not None:
-                prior_release_version = prior.version
-
-        defaults_dict = {
-            "revision_number": release.unwrap_revision_number,
-            "subject_template_hash": subject_template_hash,
-            "body": default_body,
-            "auto_archive": bool(release.archive_prior_release),
-            "auto_archive_release": prior_release_version or "",
-        }
-
-        skip = ["email_cc", "email_bcc"]
-        if not prior_release_version:
-            # Prior release version will only be set if the options for archival are True *and* we found a release
-            skip.extend(["auto_archive", "auto_archive_release"])
-        await form.render_block(
-            page,
-            model_cls=shared.announce.AnnounceForm,
-            action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version),
-            submit_label="Publish & announce",
-            defaults=defaults_dict,
-            custom={
-                "email_to": to_radios,
-                "subject": custom_subject_widget,
-                "body": custom_body_widget,
-                "download_path_suffix": download_path_widget,
-            },
-            skip=skip,
-            form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
-            border=True,
-            wider_widgets=True,
-        )
-        page.append(htpy.div("#announce-body-config.d-none", data_preview_url=preview_url))
-    else:
+    announce_msg = _missing_distributions_message(release)
+    if announce_msg:
         page.p[htm.strong[announce_msg]]
+        return page.collect()
 
+    page.p["This form will send an announcement to the selected recipients."]
+    await _render_announce_form(
+        page,
+        release,
+        permitted_recipients=permitted_recipients,
+        subject_template_hash=subject_template_hash,
+        default_subject=default_subject,
+        default_body=default_body,
+        default_download_path_value=default_download_path_value,
+        download_path_description=download_path_description,
+        preview_url=preview_url,
+    )
     return page.collect()
 
 
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 6f45c3c6..700b6ca3 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -269,6 +269,27 @@ async def selected(  # noqa: C901
         skip.append("automatic_resolve_when_finished")
         skip.append("notify_when_finished")
 
+    if form_cls is shared.resolve.SubmitForm:
+        # The result reuses whatever list the vote ran on; show it for reference
+        defaults["result_email_to"] = vote_recipient or "the original vote thread recipients"
+        defaults["result_subject"] = tabulate.vote_result_subject(release, "<resolution>")
+        if release.committee is not None:
+            private_list_address = f"private@{release.committee.key}.apache.org"
+            custom["bcc_private_list"] = htm.div[
+                htpy.input(
+                    type="checkbox",
+                    name="bcc_private_list",
+                    id="bcc_private_list",
+                    value="on",
+                    class_="form-check-input",
+                ),
+                htm.div(".form-text.text-muted.mt-1")[
+                    f"If set, ATR will also send a blind copy of the result to {private_list_address}.",
+                ],
+            ]
+        else:
+            skip.append("bcc_private_list")
+
     pre_submit: htm.Element | None = None
     if (not binding_sufficient) and (pass_fail_allowed or bypass_active):
         icon = htpy.i(class_="bi bi-exclamation-triangle me-1")
diff --git a/atr/post/announce.py b/atr/post/announce.py
index a22cabbf..555cf19b 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -73,9 +73,6 @@ async def selected(
     URL: /announce/<project_key>/<version_key>
     Handle the announcement form submission and promote the preview to release.
     """
-    if response := await _validate_recipients(session, announce_form):
-        return response
-
     release = await session.release(
         project_key,
         version_key,
@@ -87,6 +84,11 @@ async def selected(
     )
     preview_revision_number = release.safe_latest_revision_number
 
+    if (committee := release.project.committee) is None:
+        raise ValueError("Release has no committee")
+    if response := await _validate_recipients(session, announce_form, util.unwrap(committee.key)):
+        return response
+
     if announce_form.revision_number != preview_revision_number:
         return await session.redirect(
             get.announce.selected,
@@ -166,9 +168,9 @@ async def _validate_distributions(
 
 
 async def _validate_recipients(
-    session: web.Committer, announce_form: shared.announce.AnnounceForm
+    session: web.Committer, announce_form: shared.announce.AnnounceForm, committee_key: str
 ) -> web.WerkzeugResponse | None:
-    permitted = util.permitted_announce_recipients(session.uid)
+    permitted = util.permitted_announce_recipients(session.uid, committee_key=committee_key)
     addresses = [announce_form.email_to, *announce_form.email_cc, *announce_form.email_bcc]
     for addr in addresses:
         if addr not in permitted:
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 173899d0..8ac71871 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -51,6 +51,7 @@ async def selected(
 
     automatic_resolve_when_finished = _read_auto_resolve_flag(submit_form)
     notify_when_finished = _read_notify_flag(submit_form)
+    bcc_private_list = _read_bcc_private_flag(submit_form)
 
     try:
         async with storage.write_as_project_committee_member(project_key) as wacm:
@@ -64,6 +65,7 @@ async def selected(
                 expected_vote_mode=submit_form.vote_mode,
                 automatic_resolve_when_finished=automatic_resolve_when_finished,
                 notify_when_finished=notify_when_finished,
+                bcc_private_list=bcc_private_list,
             )
     except storage.AccessError as e:
         return await session.redirect(
@@ -97,6 +99,12 @@ def _read_auto_resolve_flag(submit_form: shared.resolve.ResolveForm) -> bool:
     return False
 
 
+def _read_bcc_private_flag(submit_form: shared.resolve.ResolveForm) -> bool:
+    if isinstance(submit_form, shared.resolve.SubmitForm):
+        return bool(submit_form.bcc_private_list)
+    return False
+
+
 def _read_notify_flag(submit_form: shared.resolve.ResolveForm) -> bool:
     if isinstance(submit_form, shared.resolve.SubmitForm):
         return bool(submit_form.notify_when_finished)
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index 499e3a24..a929c98d 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -47,7 +47,14 @@ class CancelSubmitForm(form.Form):
 
 class SubmitForm(form.Form):
     variant: SUBMIT = form.value(SUBMIT)
-    email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000)
+    result_email_to: str = form.label("To", widget=form.Widget.STATIC, default="")
+    bcc_private_list: form.Bool = form.label(
+        "BCC private@",
+        widget=form.Widget.CUSTOM,
+        default=False,
+    )
+    result_subject: str = form.label("Subject", widget=form.Widget.STATIC, default="")
+    email_body: str = form.label("Body", widget=form.Widget.TEXTAREA, max_length=100_000)
     vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote result", widget=form.Widget.RADIO)
     automatic_resolve_when_finished: form.Bool = form.label(
         "Automatically resolve the second round vote",
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 0fc7c52a..da430f72 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -123,7 +123,7 @@ class CommitteeMember(CommitteeParticipant):
         email_cc: list[str] | None = None,
         email_bcc: list[str] | None = None,
     ) -> None:
-        permitted = util.permitted_announce_recipients(self.__asf_uid)
+        permitted = util.permitted_announce_recipients(self.__asf_uid, committee_key=self.__committee_key)
         all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
         for addr in all_addrs:
             if addr not in permitted:
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index e3c46dfd..efb82aad 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -473,6 +473,7 @@ class CommitteeMember(CommitteeParticipant):
         expected_vote_mode: sql.VoteMode | None = None,
         automatic_resolve_when_finished: bool = False,
         notify_when_finished: bool = False,
+        bcc_private_list: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         release = await self.__data.release(
             key=sql.release_key(str(project_key), str(version_key)),
@@ -514,6 +515,7 @@ class CommitteeMember(CommitteeParticipant):
                 podling_round_one_thread_id,
                 automatic_resolve_when_finished=automatic_resolve_when_finished,
                 notify_when_finished=notify_when_finished,
+                bcc_private_list=bcc_private_list,
             )
         if (
             (expected_vote_seq is not None)
@@ -557,6 +559,7 @@ class CommitteeMember(CommitteeParticipant):
             resolution_body,
             automatic_resolve_when_finished=automatic_resolve_when_finished,
             notify_when_finished=notify_when_finished,
+            bcc_private_list=bcc_private_list,
         )
 
     async def resolve_manually(
@@ -641,6 +644,7 @@ class CommitteeMember(CommitteeParticipant):
         resolution_body: str,
         automatic_resolve_when_finished: bool = False,
         notify_when_finished: bool = False,
+        bcc_private_list: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         if (voting_round == 1) and (vote_result == "passed"):
             await self.__data.commit()
@@ -774,6 +778,7 @@ class CommitteeMember(CommitteeParticipant):
             asf_fullname,
             latest_vote_task,
             extra_destination=extra_destination,
+            bcc_private_list=bcc_private_list,
         )
         # TODO: Could move this up before send_resolution
         if (second_round_vote_seq is not None) and (second_round_vote_mode is not None):
@@ -807,6 +812,7 @@ class CommitteeMember(CommitteeParticipant):
         asf_fullname: str,
         latest_vote_task: sql.Task,
         extra_destination: tuple[str, str] | None = None,
+        bcc_private_list: bool = False,
     ) -> str | None:
         storage.ensure_project_active(release.project)
         # Get the email thread
@@ -821,8 +827,14 @@ class CommitteeMember(CommitteeParticipant):
         email_to: str = latest_vote_task.task_args["email_to"]
         email_cc: list[str] = latest_vote_task.task_args.get("email_cc", [])
         email_bcc: list[str] = latest_vote_task.task_args.get("email_bcc", [])
+        # The send task rejects duplicate recipients case-insensitively, so guard before adding
+        if bcc_private_list and (release.committee is not None):
+            private_list_address = f"private@{release.committee.key}.apache.org"
+            existing = {addr.lower() for addr in [email_to, *email_cc, *email_bcc]}
+            if private_list_address.lower() not in existing:
+                email_bcc = [*email_bcc, private_list_address]
         email_sender = f"{self.__asf_uid}@apache.org"
-        subject = f"[VOTE] [RESULT] Release {release.project.display_name} {release.version} {resolution.upper()}"
+        subject = tabulate.vote_result_subject(release, resolution)
         # TODO: This duplicates atr/tabulate.py code
         # There are arguments for using this code instead:
         # - It enforces a consistent style
@@ -890,6 +902,7 @@ class CommitteeMember(CommitteeParticipant):
         *,
         automatic_resolve_when_finished: bool = False,
         notify_when_finished: bool = False,
+        bcc_private_list: bool = False,
     ) -> tuple[sql.Release, int | None, str, str | None]:
         latest_vote_task: sql.Task | None = None
         second_round_vote_mode: sql.VoteMode | None = None
@@ -1061,6 +1074,7 @@ class CommitteeMember(CommitteeParticipant):
                 asf_fullname,
                 latest_vote_task,
                 extra_destination=extra_destination,
+                bcc_private_list=bcc_private_list,
             )
 
         self.__write_as.append_to_audit_log(
diff --git a/atr/tabulate.py b/atr/tabulate.py
index bab1b7a7..23ec25dd 100644
--- a/atr/tabulate.py
+++ b/atr/tabulate.py
@@ -168,6 +168,10 @@ def vote_resolution(
     )
 
 
+def vote_result_subject(release: sql.Release, resolution: str) -> str:
+    return f"[VOTE] [RESULT] Release {release.project.display_name} {release.version} {resolution.upper()}"
+
+
 def vote_summary(tabulated_votes: dict[str, models.tabulate.VoteEmail]) -> dict[str, int]:
     result = {
         "binding_votes": 0,
diff --git a/atr/util.py b/atr/util.py
index 2914ec0c..5ae12cad 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -865,16 +865,19 @@ def paths_to_inodes(directory: os.PathLike) -> dict[str, int]:
     return result
 
 
-def permitted_announce_recipients(asf_uid: str) -> list[str]:
-    recipients = [
-        # f"dev@{committee.name}.apache.org",
-        # f"private@{committee.name}.apache.org",
-    ]
+def permitted_announce_recipients(asf_uid: str, committee_key: str | None = None) -> list[str]:
     if config.get().ATR_STATUS == "ALPHA":
-        recipients.append(USER_TESTS_ADDRESS)
-        recipients.append(f"{asf_uid}@apache.org")
-    else:
-        recipients.append("announce@apache.org")
+        return [USER_TESTS_ADDRESS, f"{asf_uid}@apache.org"]
+    recipients = ["announce@apache.org"]
+    if committee_key is not None:
+        recipients.extend(
+            [
+                f"announce@{committee_key}.apache.org",
+                f"dev@{committee_key}.apache.org",
+                f"user@{committee_key}.apache.org",
+                f"private@{committee_key}.apache.org",
+            ]
+        )
     return recipients
 
 


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