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