This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 1c96d34 Allow the configuration of vote and announcement subject
templates
1c96d34 is described below
commit 1c96d34071545b022bd784f1f160f2af18c06dab
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Dec 31 19:17:25 2025 +0000
Allow the configuration of vote and announcement subject templates
---
atr/construct.py | 61 ++++++++++++++++++--
atr/get/announce.py | 17 ++++--
atr/get/projects.py | 75 ++++++++++++++++++++++++-
atr/get/voting.py | 21 +++++--
atr/models/sql.py | 22 ++++++++
atr/shared/projects.py | 8 +++
atr/storage/writers/policy.py | 34 +++++++++++
atr/storage/writers/vote.py | 35 +++++++-----
migrations/versions/0034_2025.12.31_ac4dcf44.py | 29 ++++++++++
9 files changed, 270 insertions(+), 32 deletions(-)
diff --git a/atr/construct.py b/atr/construct.py
index 2066ffc..40c709f 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -27,20 +27,20 @@ import atr.db.interaction as interaction
import atr.models.sql as sql
import atr.util as util
-type Context = Literal["announce", "checklist", "vote"]
+type Context = Literal["announce", "announce_subject", "checklist", "vote",
"vote_subject"]
TEMPLATE_VARIABLES: list[tuple[str, str, set[Context]]] = [
("CHECKLIST_URL", "URL to the release checklist", {"vote"}),
- ("COMMITTEE", "Committee display name", {"announce", "checklist", "vote"}),
+ ("COMMITTEE", "Committee display name", {"announce", "checklist", "vote",
"vote_subject"}),
("DOWNLOAD_URL", "URL to download the release", {"announce"}),
("DURATION", "Vote duration in hours", {"vote"}),
("KEYS_FILE", "URL to the KEYS file", {"vote"}),
- ("PROJECT", "Project display name", {"announce", "checklist", "vote"}),
+ ("PROJECT", "Project display name", {"announce", "announce_subject",
"checklist", "vote", "vote_subject"}),
("RELEASE_CHECKLIST", "Release checklist content", {"vote"}),
("REVIEW_URL", "URL to review the release", {"checklist", "vote"}),
- ("REVISION", "Revision number", {"announce", "checklist", "vote"}),
- ("TAG", "Revision tag, if set", {"announce", "checklist", "vote"}),
- ("VERSION", "Version name", {"announce", "checklist", "vote"}),
+ ("REVISION", "Revision number", {"announce", "checklist", "vote",
"vote_subject"}),
+ ("TAG", "Revision tag, if set", {"announce", "checklist", "vote",
"vote_subject"}),
+ ("VERSION", "Version name", {"announce", "announce_subject", "checklist",
"vote", "vote_subject"}),
("VOTE_ENDS_UTC", "Vote end date and time in UTC", {"vote"}),
("YOUR_ASF_ID", "Your Apache UID", {"announce", "vote"}),
("YOUR_FULL_NAME", "Your full name", {"announce", "vote"}),
@@ -120,6 +120,26 @@ async def announce_release_default(project_name: str) ->
str:
return project.policy_announce_release_template
+def announce_release_subject(subject: str, options: AnnounceReleaseOptions) ->
str:
+ subject = subject.replace("{{PROJECT}}", options.project_name)
+ subject = subject.replace("{{VERSION}}", options.version_name)
+
+ return subject
+
+
+async def announce_release_subject_default(project_name: str) -> str:
+ async with db.session() as data:
+ project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+ RuntimeError(f"Project {project_name} not found")
+ )
+
+ return project.policy_announce_release_subject
+
+
+def announce_subject_template_variables() -> list[tuple[str, str]]:
+ return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if
"announce_subject" in contexts]
+
+
def announce_template_variables() -> list[tuple[str, str]]:
return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if
"announce" in contexts]
@@ -244,5 +264,34 @@ async def start_vote_default(project_name: str) -> str:
return project.policy_start_vote_template
+def start_vote_subject(
+ subject: str,
+ options: StartVoteOptions,
+ revision_number: str,
+ revision_tag: str,
+ committee_name: str,
+) -> str:
+ subject = subject.replace("{{COMMITTEE}}", committee_name)
+ subject = subject.replace("{{PROJECT}}", options.project_name)
+ subject = subject.replace("{{REVISION}}", revision_number)
+ subject = subject.replace("{{TAG}}", revision_tag)
+ subject = subject.replace("{{VERSION}}", options.version_name)
+
+ return subject
+
+
+async def start_vote_subject_default(project_name: str) -> str:
+ async with db.session() as data:
+ project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+ RuntimeError(f"Project {project_name} not found")
+ )
+
+ return project.policy_start_vote_subject
+
+
+def vote_subject_template_variables() -> list[tuple[str, str]]:
+ return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if
"vote_subject" in contexts]
+
+
def vote_template_variables() -> list[tuple[str, str]]:
return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if
"vote" in contexts]
diff --git a/atr/get/announce.py b/atr/get/announce.py
index a24f60a..2c2d5c8 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -43,14 +43,19 @@ async def selected(session: web.Committer, project_name:
str, version_name: str)
project_name, version_name, with_committee=True,
phase=sql.ReleasePhase.RELEASE_PREVIEW
)
- # Variables used in defaults for subject and body
- project_display_name = release.project.display_name or release.project.name
-
- # The subject cannot be changed by the user
- default_subject = f"[ANNOUNCE] {project_display_name} {version_name}
released"
- # The body can be changed, either from VoteTemplate or from the form
+ # Get the templates from the release policy
+ default_subject_template = await
construct.announce_release_subject_default(project_name)
default_body = await construct.announce_release_default(project_name)
+ # Expand the subject template
+ options = construct.AnnounceReleaseOptions(
+ asfuid=session.uid,
+ fullname=session.fullname,
+ project_name=release.project.display_name or project_name,
+ version_name=version_name,
+ )
+ default_subject =
construct.announce_release_subject(default_subject_template, options)
+
# The download path suffix can be changed
# The defaults depend on whether the project is top level or not
if (committee := release.project.committee) is None:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 2003a82..085f4ff 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -215,6 +215,59 @@ async def view(session: web.Committer, name: str) ->
web.WerkzeugResponse | str:
)
+def _input_with_variables(
+ field_name: str,
+ default_value: str,
+ template_variables: list[tuple[str, str]],
+ documentation: str | None = None,
+) -> htm.Element:
+ text_input = htpy.input(
+ f"#{field_name}.form-control.font-monospace",
+ type="text",
+ name=field_name,
+ value=default_value,
+ )
+
+ variable_rows = []
+ for name, description in template_variables:
+ variable_rows.append(
+ htm.tr[
+ htm.td(".font-monospace.text-nowrap.py-1")[f"{{{{{name}}}}}"],
+ htm.td(".py-1")[description],
+ htm.td(".text-end.py-1")[
+ htpy.button(
+ ".btn.btn-sm.btn-outline-secondary.copy-var-btn",
+ type="button",
+ data_variable=f"{{{{{name}}}}}",
+ )["Copy"]
+ ],
+ ]
+ )
+
+ variables_table = htm.table(".table.table-sm.mb-0")[
+ htm.thead[
+ htm.tr[
+ htm.th(".py-1")["Variable"],
+ htm.th(".py-1")["Description"],
+ htm.th(".py-1")[""],
+ ]
+ ],
+ htm.tbody[*variable_rows],
+ ]
+
+ details = htm.details(".mt-2")[
+ htm.summary(".text-muted")["Available template variables"],
+ htm.div(".mt-2")[variables_table],
+ ]
+
+ elements: list[htm.Element | htm.VoidElement] = [text_input]
+ if documentation:
+ elements.append(htm.div(".text-muted.mt-1.form-text")[documentation])
+ elements.append(details)
+
+ return htm.div[elements]
+
+
def _render_categories_section(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["Categories"]]
@@ -322,6 +375,13 @@ def _render_finish_form(project: sql.Project) ->
htm.Element:
htm.h3(".mb-0")["Release policy - Finish options"]
]
+ announce_release_subject_widget = _input_with_variables(
+ field_name="announce_release_subject",
+ default_value=project.policy_announce_release_subject or "",
+ template_variables=construct.announce_subject_template_variables(),
+ documentation="Subject line template for announcement emails.",
+ )
+
announce_release_template_widget = _textarea_with_variables(
field_name="announce_release_template",
default_value=project.policy_announce_release_template or "",
@@ -339,6 +399,7 @@ def _render_finish_form(project: sql.Project) ->
htm.Element:
defaults={
"project_name": project.name,
"github_finish_workflow_path":
"\n".join(project.policy_github_finish_workflow_path),
+ "announce_release_subject":
project.policy_announce_release_subject or "",
"announce_release_template":
project.policy_announce_release_template or "",
"preserve_download_files":
project.policy_preserve_download_files,
},
@@ -346,7 +407,10 @@ def _render_finish_form(project: sql.Project) ->
htm.Element:
border=True,
# wider_widgets=True,
textarea_rows=10,
- custom={"announce_release_template":
announce_release_template_widget},
+ custom={
+ "announce_release_subject": announce_release_subject_widget,
+ "announce_release_template": announce_release_template_widget,
+ },
)
return card.collect()
@@ -539,6 +603,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
"pause_for_rm": project.policy_pause_for_rm,
"release_checklist": project.policy_release_checklist or "",
"vote_comment_template": project.policy_vote_comment_template or "",
+ "start_vote_subject": project.policy_start_vote_subject or "",
"start_vote_template": project.policy_start_vote_template or "",
}
@@ -552,6 +617,13 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
documentation="Markdown text describing how to test release
candidates.",
)
+ start_vote_subject_widget = _input_with_variables(
+ field_name="start_vote_subject",
+ default_value=project.policy_start_vote_subject or "",
+ template_variables=construct.vote_subject_template_variables(),
+ documentation="Subject line template for vote emails.",
+ )
+
start_vote_template_widget = _textarea_with_variables(
field_name="start_vote_template",
default_value=project.policy_start_vote_template or "",
@@ -574,6 +646,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
skip=skip_fields,
custom={
"release_checklist": release_checklist_widget,
+ "start_vote_subject": start_vote_subject_widget,
"start_vote_template": start_vote_template_widget,
},
)
diff --git a/atr/get/voting.py b/atr/get/voting.py
index f12b7a0..e5e9f13 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -64,15 +64,24 @@ async def selected_revision(
min_hours = release.release_policy.min_hours
revision_obj = await data.revision(release_name=release.name,
number=revision).get()
- if revision_obj and revision_obj.tag:
- subject_suffix = f" ({revision_obj.tag})"
- else:
- subject_suffix = f" (revision {revision})"
+ revision_number = str(revision)
+ revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag)
else ""
- # TODO: Add the draft revision number or tag to the subject
- default_subject = f"[VOTE] Release {release.project.display_name}
{release.version}{subject_suffix}"
+ default_subject_template = await
construct.start_vote_subject_default(project_name)
default_body = await construct.start_vote_default(project_name)
+ options = construct.StartVoteOptions(
+ asfuid=session.uid,
+ fullname=session.fullname,
+ project_name=release.project.display_name or project_name,
+ version_name=release.version,
+ vote_duration=min_hours,
+ vote_end="",
+ )
+ default_subject = construct.start_vote_subject(
+ default_subject_template, options, revision_number, revision_tag,
committee.display_name
+ )
+
keys_warning = await _check_keys_warning(committee)
content = await _render_page(
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5991d2d..e2ddadc 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -555,6 +555,10 @@ On behalf of the Apache {{COMMITTEE}} project team,
{{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}})
"""
+ @property
+ def policy_announce_release_subject_default(self) -> str:
+ return "[ANNOUNCE] {{PROJECT}} {{VERSION}} released"
+
@property
def policy_start_vote_default(self) -> str:
return """Hello {{COMMITTEE}},
@@ -587,10 +591,20 @@ Thanks,
{{YOUR_FULL_NAME}} ({{YOUR_ASF_ID}})
"""
+ @property
+ def policy_start_vote_subject_default(self) -> str:
+ return "[VOTE] Release {{PROJECT}} {{VERSION}}"
+
@property
def policy_default_min_hours(self) -> int:
return 72
+ @property
+ def policy_announce_release_subject(self) -> str:
+ if ((policy := self.release_policy) is None) or
(policy.announce_release_subject == ""):
+ return self.policy_announce_release_subject_default
+ return policy.announce_release_subject
+
@property
def policy_announce_release_template(self) -> str:
if ((policy := self.release_policy) is None) or
(policy.announce_release_template == ""):
@@ -638,6 +652,12 @@ Thanks,
return ""
return policy.vote_comment_template
+ @property
+ def policy_start_vote_subject(self) -> str:
+ if ((policy := self.release_policy) is None) or
(policy.start_vote_subject == ""):
+ return self.policy_start_vote_subject_default
+ return policy.start_vote_subject
+
@property
def policy_start_vote_template(self) -> str:
if ((policy := self.release_policy) is None) or
(policy.start_vote_template == ""):
@@ -1001,7 +1021,9 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
release_checklist: str = sqlmodel.Field(default="")
vote_comment_template: str = sqlmodel.Field(default="")
pause_for_rm: bool = sqlmodel.Field(default=False)
+ start_vote_subject: str = sqlmodel.Field(default="")
start_vote_template: str = sqlmodel.Field(default="")
+ announce_release_subject: str = sqlmodel.Field(default="")
announce_release_template: str = sqlmodel.Field(default="")
binary_artifact_paths: list[str] = sqlmodel.Field(
default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 20344e5..aec1233 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -191,6 +191,10 @@ class VotePolicyForm(form.Form):
"Plain text template for vote comments. Voters can edit before
submitting.",
widget=form.Widget.TEXTAREA,
)
+ start_vote_subject: str = form.label(
+ "Start vote subject",
+ widget=form.Widget.CUSTOM,
+ )
start_vote_template: str = form.label(
"Start vote template",
widget=form.Widget.CUSTOM,
@@ -221,6 +225,10 @@ class FinishPolicyForm(form.Form):
"The full paths to the GitHub workflows to use for the release,
including the .github/workflows/ prefix.",
widget=form.Widget.TEXTAREA,
)
+ announce_release_subject: str = form.label(
+ "Announce release subject",
+ widget=form.Widget.CUSTOM,
+ )
announce_release_template: str = form.label(
"Announce release template",
widget=form.Widget.CUSTOM,
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index cb71a7d..d49b97a 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -109,6 +109,7 @@ class CommitteeMember(CommitteeParticipant):
project, release_policy = await
self.__get_or_create_policy(project_name)
release_policy.github_finish_workflow_path =
_split_lines(form.github_finish_workflow_path)
+ self.__set_announce_release_subject(form.announce_release_subject or
"", project, release_policy)
self.__set_announce_release_template(form.announce_release_template or
"", project, release_policy)
release_policy.preserve_download_files = form.preserve_download_files
@@ -127,6 +128,7 @@ class CommitteeMember(CommitteeParticipant):
release_policy.pause_for_rm = form.pause_for_rm
release_policy.release_checklist = form.release_checklist or ""
release_policy.vote_comment_template = form.vote_comment_template
or ""
+ self.__set_start_vote_subject(form.start_vote_subject or "",
project, release_policy)
self.__set_start_vote_template(form.start_vote_template or "",
project, release_policy)
elif project.committee and project.committee.is_podling:
raise storage.AccessError("Manual voting is not allowed for
podlings.")
@@ -153,6 +155,22 @@ class CommitteeMember(CommitteeParticipant):
return project, release_policy
+ def __set_announce_release_subject(
+ self,
+ submitted_subject: str,
+ project: models.sql.Project,
+ release_policy: models.sql.ReleasePolicy,
+ ) -> None:
+ submitted_subject = submitted_subject.strip()
+ current_default_text = project.policy_announce_release_subject_default
+ current_default_hash =
util.compute_sha3_256(current_default_text.encode())
+ submitted_hash = util.compute_sha3_256(submitted_subject.encode())
+
+ if submitted_hash == current_default_hash:
+ release_policy.announce_release_subject = ""
+ else:
+ release_policy.announce_release_subject = submitted_subject
+
def __set_announce_release_template(
self,
submitted_template: str,
@@ -182,6 +200,22 @@ class CommitteeMember(CommitteeParticipant):
else:
release_policy.min_hours = submitted_min_hours
+ def __set_start_vote_subject(
+ self,
+ submitted_subject: str,
+ project: models.sql.Project,
+ release_policy: models.sql.ReleasePolicy,
+ ) -> None:
+ submitted_subject = submitted_subject.strip()
+ current_default_text = project.policy_start_vote_subject_default
+ current_default_hash =
util.compute_sha3_256(current_default_text.encode())
+ submitted_hash = util.compute_sha3_256(submitted_subject.encode())
+
+ if submitted_hash == current_default_hash:
+ release_policy.start_vote_subject = ""
+ else:
+ release_policy.start_vote_subject = submitted_subject
+
def __set_start_vote_template(
self,
submitted_template: str,
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index ce2aa65..f188a68 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -174,19 +174,28 @@ class CommitteeParticipant(FoundationCommitter):
vote_end = vote_start + datetime.timedelta(hours=vote_duration_choice)
vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
- # Perform template substitutions in the body before passing to task
+ options = construct.StartVoteOptions(
+ asfuid=asf_uid,
+ fullname=asf_fullname,
+ project_name=project_name,
+ version_name=version_name,
+ vote_duration=vote_duration_choice,
+ vote_end=vote_end_str,
+ )
+
+ # Get revision tag for subject substitution
+ revision_obj = await self.__data.revision(release_name=release.name,
number=selected_revision_number).get()
+ revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag)
else ""
+
+ # Get committee name for subject substitution
+ committee_name = release.committee.display_name if release.committee
else ""
+
+ # Perform template substitutions before passing to task
# This must be done here and not in the task because we need
util.as_url
- body_substituted = await construct.start_vote_body(
- body_data,
- construct.StartVoteOptions(
- asfuid=asf_uid,
- fullname=asf_fullname,
- project_name=project_name,
- version_name=version_name,
- vote_duration=vote_duration_choice,
- vote_end=vote_end_str,
- ),
+ subject_substituted = construct.start_vote_subject(
+ subject_data, options, selected_revision_number, revision_tag,
committee_name
)
+ body_substituted = await construct.start_vote_body(body_data, options)
# Create a task for vote initiation
task = sql.Task(
@@ -198,7 +207,7 @@ class CommitteeParticipant(FoundationCommitter):
vote_duration=vote_duration_choice,
initiator_id=asf_uid,
initiator_fullname=asf_fullname,
- subject=subject_data,
+ subject=subject_substituted,
body=body_substituted,
).model_dump(),
asf_uid=asf_uid,
@@ -349,7 +358,7 @@ class CommitteeMember(CommitteeParticipant):
asf_uid=self.__asf_uid,
asf_fullname=asf_fullname,
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
- subject_data=f"[VOTE] Release {release.project.display_name}
{release.version}",
+ subject_data=await
construct.start_vote_subject_default(release.project.name),
body_data=await
construct.start_vote_default(release.project.name),
release=release,
promote=False,
diff --git a/migrations/versions/0034_2025.12.31_ac4dcf44.py
b/migrations/versions/0034_2025.12.31_ac4dcf44.py
new file mode 100644
index 0000000..8cb4c82
--- /dev/null
+++ b/migrations/versions/0034_2025.12.31_ac4dcf44.py
@@ -0,0 +1,29 @@
+"""Add subject template options to release policies
+
+Revision ID: 0034_2025.12.31_ac4dcf44
+Revises: 0033_2025.12.31_f2d97d96
+Create Date: 2025-12-31 18:59:47.025592+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0034_2025.12.31_ac4dcf44"
+down_revision: str | None = "0033_2025.12.31_f2d97d96"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("start_vote_subject", sa.String(),
nullable=False, server_default=""))
+ batch_op.add_column(sa.Column("announce_release_subject", sa.String(),
nullable=False, server_default=""))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.drop_column("announce_release_subject")
+ batch_op.drop_column("start_vote_subject")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]