You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@burr.apache.org by eb...@apache.org on 2026/06/04 04:11:30 UTC
(burr) branch main updated: feat: add release email generation tooling (#743)
This is an automated email from the ASF dual-hosted git repository.
elijahbenizzy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/burr.git
The following commit(s) were added to refs/heads/main by this push:
new c2b752ed feat: add release email generation tooling (#743)
c2b752ed is described below
commit c2b752ed53553c0ac8f5bd24f5309cd4a466827e
Author: Shabbir Hussain <72...@gmail.com>
AuthorDate: Wed Jun 3 21:11:26 2026 -0700
feat: add release email generation tooling (#743)
* feat: add release email generation tooling
* fix: handle binding no votes in result email
* fix: make adaptive crag example python 3.10 compatible
---
.github/workflows/release-validation.yml | 2 +-
.gitignore | 1 +
examples/adaptive-crag/application.py | 2 +-
pyproject.toml | 4 +
scripts/README.md | 5 +
scripts/apache_release.py | 476 ++++++++++++++++++++++++++++---
scripts/templates/announce_email.j2 | 40 +++
scripts/templates/result_email.j2 | 39 +++
scripts/templates/vote_email.j2 | 68 +++++
tests/test_apache_release_email.py | 180 ++++++++++++
10 files changed, 771 insertions(+), 46 deletions(-)
diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml
index be8b6652..ba1a6c5a 100644
--- a/.github/workflows/release-validation.yml
+++ b/.github/workflows/release-validation.yml
@@ -100,7 +100,7 @@ jobs:
run: sudo apt-get install -y --no-install-recommends graphviz
- name: Install Python build deps
- run: pip install flit twine
+ run: pip install flit twine jinja2
- name: Cache Apache RAT
id: cache-rat
diff --git a/.gitignore b/.gitignore
index 8a87fe7a..e84ab416 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,6 +129,7 @@ ipython_config.py
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
+uv.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
diff --git a/examples/adaptive-crag/application.py b/examples/adaptive-crag/application.py
index 3b08c704..912402e1 100644
--- a/examples/adaptive-crag/application.py
+++ b/examples/adaptive-crag/application.py
@@ -282,7 +282,7 @@ def router(state: State, query: str, attempts: int = ATTEMPTS) -> tuple[dict[str
table_names = db.table_names()
chat_history = state["chat_history"]
# using this as a `response_model` to ensure the route is valid
- routes = Literal[*table_names, "web_search", "assistant"] # type: ignore
+ routes = Literal.__getitem__((*table_names, "web_search", "assistant")) # type: ignore
try:
route = ask_gemini.create(
messages=[
diff --git a/pyproject.toml b/pyproject.toml
index 3260eca3..c2ab5d20 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -81,6 +81,10 @@ redis = [
"redis"
]
+release = [
+ "jinja2",
+]
+
tests = [
"pytest",
"pytest-asyncio",
diff --git a/scripts/README.md b/scripts/README.md
index 522cf073..7c11036e 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -101,6 +101,11 @@ python scripts/apache_release.py verify 0.41.0 0
# Skip upload step in 'all' command
python scripts/apache_release.py all 0.41.0 0 your_apache_id --no-upload
+
+# Generate release emails from templates
+python scripts/apache_release.py vote-email --version 0.41.0 --rc 0
+python scripts/apache_release.py result-email --version 0.41.0 --rc 0 --binding-yes 3 --non-binding-yes 2 --binding-no 0 --non-binding-no 1
+python scripts/apache_release.py announce-email --version 0.41.0
```
Output: `dist/` directory with tar.gz (archive + sdist), whl, plus .asc and .sha512 files. The wheel is validated with `twine check` to ensure metadata correctness before signing. Install from the whl file to test it out after running the `wheel` subcommand.
diff --git a/scripts/apache_release.py b/scripts/apache_release.py
index 49ea80c7..1d823fe1 100644
--- a/scripts/apache_release.py
+++ b/scripts/apache_release.py
@@ -39,12 +39,16 @@ import shutil
import subprocess
import sys
import tempfile
-from typing import NoReturn, Optional
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, NoReturn, Optional
# --- Configuration ---
PROJECT_SHORT_NAME = "burr"
VERSION_FILE = "pyproject.toml"
VERSION_PATTERN = r'version\s*=\s*"(\d+\.\d+\.\d+)"'
+TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
+DEFAULT_DOWNLOADS_URL = f"https://downloads.apache.org/incubator/{PROJECT_SHORT_NAME}/"
DEFAULT_DEV_SVN_ROOT = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}"
DEFAULT_RELEASE_SVN_ROOT = (
f"https://dist.apache.org/repos/dist/release/incubator/{PROJECT_SHORT_NAME}"
@@ -63,7 +67,6 @@ REQUIRED_EXAMPLES = [
"hello-world-counter",
]
-
# ============================================================================
# Utility Functions
# ============================================================================
@@ -128,6 +131,257 @@ def _run_command(
_fail(f"{error_message}{error_detail}")
+def _render_template(template_name: str, context: dict[str, Any]) -> str:
+ """Render a template with Jinja2."""
+ template_path = TEMPLATES_DIR / template_name
+
+ if not template_path.exists():
+ _fail(f"Template not found: {template_path}")
+
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
+
+ environment = Environment(
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
+ autoescape=False,
+ keep_trailing_newline=True,
+ trim_blocks=True,
+ lstrip_blocks=True,
+ undefined=StrictUndefined,
+ )
+ return environment.get_template(template_name).render(**context)
+
+
+def _clipboard_commands() -> list[list[str]]:
+ """Return clipboard commands for macOS, Linux, and Windows."""
+ return [
+ ["pbcopy"], # macOS
+ ["xclip", "-selection", "clipboard"], # Linux with xclip
+ ["xsel", "--clipboard", "--input"], # Linux with xsel
+ ["clip"], # Windows
+ ]
+
+
+def _copy_to_clipboard(content: str) -> bool:
+ """Copy content to the system clipboard when a known clipboard tool exists."""
+ for command in _clipboard_commands():
+ if shutil.which(command[0]) is None:
+ continue
+ try:
+ subprocess.run(command, input=content, text=True, check=True)
+ return True
+ except subprocess.CalledProcessError:
+ continue
+ return False
+
+
+def _emit_email_output(content: str, copy_to_clipboard: bool = False) -> None:
+ """Emit rendered email to stdout and optionally copy it to the clipboard."""
+ print(content)
+ if copy_to_clipboard:
+ if _copy_to_clipboard(content):
+ print("\n Copied email content to clipboard", file=sys.stderr)
+ else:
+ print(
+ "\n Clipboard tool not found; email content was printed to stdout instead",
+ file=sys.stderr,
+ )
+
+
+def _parse_semver(version: str) -> tuple[int, int, int]:
+ """Parse an X.Y.Z version string into a sortable tuple."""
+ match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version)
+ if not match:
+ _fail(f"Invalid version format: {version}")
+ return tuple(int(part) for part in match.groups())
+
+
+def _list_release_tags() -> list[tuple[tuple[int, int, int], str]]:
+ """List known release tags in the repository."""
+ try:
+ result = subprocess.run(
+ ["git", "tag", "--list"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError:
+ return []
+
+ tags: list[tuple[tuple[int, int, int], str]] = []
+ for line in result.stdout.splitlines():
+ match = re.fullmatch(r"(?:v|burr-)(\d+\.\d+\.\d+)", line.strip())
+ if match:
+ tags.append((_parse_semver(match.group(1)), line.strip()))
+ return sorted(tags)
+
+
+def _find_previous_release_tag(version: str) -> Optional[str]:
+ """Find the most recent release tag strictly older than the requested version."""
+ target = _parse_semver(version)
+ previous_tags = [tag for parsed, tag in _list_release_tags() if parsed < target]
+ if not previous_tags:
+ return None
+ return previous_tags[-1]
+
+
+def _find_release_tag(version: str) -> Optional[str]:
+ """Find the exact release tag for a version, if it exists."""
+ target = _parse_semver(version)
+ matching_tags = [tag for parsed, tag in _list_release_tags() if parsed == target]
+ if not matching_tags:
+ return None
+ return matching_tags[-1]
+
+
+def _build_changelog_summary(
+ version: str, previous_tag: Optional[str] = None, max_entries: int = 8
+) -> str:
+ """Summarize recent commits since the prior release tag."""
+ if previous_tag is None:
+ previous_tag = _find_previous_release_tag(version)
+ release_tag = _find_release_tag(version)
+
+ if not previous_tag:
+ return "- Changelog summary unavailable; please add a short summary before sending."
+
+ revision_range = f"{previous_tag}..{release_tag or 'HEAD'}"
+
+ try:
+ result = subprocess.run(
+ ["git", "log", revision_range, "--pretty=format:%s"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError:
+ return f"- Changelog summary unavailable; review commits in {revision_range} manually."
+
+ subjects = []
+ for line in result.stdout.splitlines():
+ cleaned = line.strip()
+ if cleaned and cleaned not in subjects:
+ subjects.append(cleaned)
+
+ if not subjects:
+ return f"- No commits found in {revision_range}; verify the tag range before sending."
+
+ summary_lines = [f"- {subject}" for subject in subjects[:max_entries]]
+ remaining = len(subjects) - len(summary_lines)
+ if remaining > 0:
+ summary_lines.append(f"- ... plus {remaining} more commits in {revision_range}")
+ return "\n".join(summary_lines)
+
+
+def _build_vote_deadline(hours: int = 72) -> datetime:
+ """Return the vote deadline timestamp in UTC."""
+ return datetime.now(timezone.utc) + timedelta(hours=hours)
+
+
+def _format_vote_deadline(deadline: datetime) -> str:
+ """Format the vote deadline for email output."""
+ return deadline.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+
+
+def _build_vote_email_context(
+ version: str,
+ rc_num: str,
+ svn_url: Optional[str] = None,
+ pypi_url: Optional[str] = None,
+ keys_url: Optional[str] = None,
+ changelog_summary: Optional[str] = None,
+ previous_tag: Optional[str] = None,
+ deadline: Optional[datetime] = None,
+) -> dict[str, str]:
+ """Build rendering context for the vote email template."""
+ version_with_incubating = f"{version}-incubating"
+ svn_url = svn_url or _build_svn_dev_url(version, rc_num)
+ deadline = deadline or _build_vote_deadline()
+ return {
+ "project_short_name": PROJECT_SHORT_NAME,
+ "project_display_name": PROJECT_SHORT_NAME.capitalize(),
+ "version": version,
+ "version_with_incubating": version_with_incubating,
+ "rc_num": rc_num,
+ "svn_url": svn_url,
+ "pypi_url": pypi_url or _build_pypi_rc_url(version, rc_num),
+ "keys_url": keys_url or _build_keys_url(),
+ "git_tag": f"v{version}-incubating-RC{rc_num}",
+ "changelog_summary": changelog_summary
+ or _build_changelog_summary(version, previous_tag=previous_tag),
+ "vote_deadline": _format_vote_deadline(deadline),
+ }
+
+
+def _build_result_email_context(
+ version: str,
+ rc_num: str,
+ binding_yes: int,
+ non_binding_yes: int,
+ abstain: int,
+ binding_no: int,
+ non_binding_no: int,
+ vote_thread_url: Optional[str] = None,
+) -> dict[str, str]:
+ """Build rendering context for the result email template."""
+ release_passed = binding_yes >= 3 and binding_yes > binding_no
+ return {
+ "project_short_name": PROJECT_SHORT_NAME,
+ "project_display_name": PROJECT_SHORT_NAME.capitalize(),
+ "version": version,
+ "version_with_incubating": f"{version}-incubating",
+ "rc_num": rc_num,
+ "binding_yes": str(binding_yes),
+ "non_binding_yes": str(non_binding_yes),
+ "abstain": str(abstain),
+ "binding_no": str(binding_no),
+ "non_binding_no": str(non_binding_no),
+ "vote_thread_url": vote_thread_url or "[add link to vote thread]",
+ "result_outcome": (
+ "Therefore, the release candidate has passed."
+ if release_passed
+ else "Therefore, the release candidate has not passed."
+ ),
+ }
+
+
+def _build_announcement_email_context(
+ version: str,
+ pypi_url: Optional[str] = None,
+ downloads_url: Optional[str] = None,
+ changelog_summary: Optional[str] = None,
+ previous_tag: Optional[str] = None,
+) -> dict[str, str]:
+ """Build rendering context for the release announcement template."""
+ return {
+ "project_short_name": PROJECT_SHORT_NAME,
+ "project_display_name": PROJECT_SHORT_NAME.capitalize(),
+ "version": version,
+ "version_with_incubating": f"{version}-incubating",
+ "pypi_url": pypi_url or f"https://pypi.org/project/apache-burr/{version}/",
+ "downloads_url": downloads_url or DEFAULT_DOWNLOADS_URL,
+ "changelog_summary": changelog_summary
+ or _build_changelog_summary(version, previous_tag=previous_tag),
+ }
+
+
+def _build_svn_dev_url(version: str, rc_num: str) -> str:
+ """Build the Apache SVN development artifacts URL for an RC."""
+ return (
+ "https://dist.apache.org/repos/dist/dev/incubator/"
+ f"{PROJECT_SHORT_NAME}/{version}-incubating-RC{rc_num}"
+ )
+
+
+def _build_keys_url() -> str:
+ """Build the Apache KEYS URL."""
+ return f"{DEFAULT_DOWNLOADS_URL}KEYS"
+
+
+def _build_pypi_rc_url(version: str, rc_num: str) -> str:
+ """Build the PyPI URL for a release candidate."""
+ return f"https://pypi.org/project/apache-burr/{version}rc{rc_num}/"
+
+
def _parse_rc_label(rc_label: str) -> tuple[str, str]:
"""Parse an RC label like 0.42.0-RC1 or 0.42.0-incubating-RC1."""
match = RC_LABEL_PATTERN.fullmatch(rc_label.strip())
@@ -156,6 +410,9 @@ def _validate_environment_for_command(args) -> None:
"promote": ["svn"],
"all": ["git", "gpg", "flit", "node", "npm", "svn", "twine"],
"verify": ["git", "gpg", "twine"],
+ "vote-email": ["git"],
+ "result-email": [],
+ "announce-email": ["git"],
}
required_tools = list(command_requirements.get(args.command, ["git", "gpg"]))
@@ -805,50 +1062,51 @@ def _upload_to_svn(
def _generate_vote_email(version: str, rc_num: str, svn_url: str) -> str:
- """Generate [VOTE] email template."""
- version_with_incubating = f"{version}-incubating"
- tag = f"v{version}-incubating-RC{rc_num}"
-
- return f"""[VOTE] Release Apache {PROJECT_SHORT_NAME} {version_with_incubating} (RC{rc_num})
-
-Hi all,
-
-This is a call for a vote on releasing Apache {PROJECT_SHORT_NAME} {version_with_incubating},
-release candidate {rc_num}.
-
-The artifacts for this release candidate can be found at:
-{svn_url}
-
-The Git tag to be voted upon is:
-{tag}
-
-Release artifacts are signed with the release manager's GPG key. The KEYS file is available at:
-https://downloads.apache.org/incubator/{PROJECT_SHORT_NAME}/KEYS
+ """Generate [VOTE] email from template."""
+ context = _build_vote_email_context(version=version, rc_num=rc_num, svn_url=svn_url)
+ return _render_template("vote_email.j2", context)
-Please download, verify, and test the release candidate.
-For detailed step-by-step instructions on how to verify this release, please see the
-"For Voters: Verifying a Release" section in the scripts/README.md file within the
-source archive.
-
-The vote will run for a minimum of 72 hours.
-Please vote:
-
-[ ] +1 Release this package as Apache {PROJECT_SHORT_NAME} {version_with_incubating}
-[ ] +0 No opinion
-[ ] -1 Do not release this package because... (reason required)
+def _generate_result_email(
+ version: str,
+ rc_num: str,
+ binding_yes: int,
+ non_binding_yes: int,
+ abstain: int,
+ binding_no: int,
+ non_binding_no: int,
+ vote_thread_url: Optional[str] = None,
+) -> str:
+ """Generate [RESULT] email from template."""
+ context = _build_result_email_context(
+ version=version,
+ rc_num=rc_num,
+ binding_yes=binding_yes,
+ non_binding_yes=non_binding_yes,
+ abstain=abstain,
+ binding_no=binding_no,
+ non_binding_no=non_binding_no,
+ vote_thread_url=vote_thread_url,
+ )
+ return _render_template("result_email.j2", context)
-Checklist for reference:
-[ ] Download links are valid
-[ ] Checksums and signatures are valid
-[ ] LICENSE/NOTICE files exist
-[ ] No unexpected binary files in source
-[ ] All source files have ASF headers
-[ ] Can compile from source
-On behalf of the Apache {PROJECT_SHORT_NAME} PPMC,
-[Your Name]
-"""
+def _generate_announcement_email(
+ version: str,
+ pypi_url: Optional[str] = None,
+ downloads_url: Optional[str] = None,
+ changelog_summary: Optional[str] = None,
+ previous_tag: Optional[str] = None,
+) -> str:
+ """Generate [ANNOUNCE] email from template."""
+ context = _build_announcement_email_context(
+ version=version,
+ pypi_url=pypi_url,
+ downloads_url=downloads_url,
+ changelog_summary=changelog_summary,
+ previous_tag=previous_tag,
+ )
+ return _render_template("announce_email.j2", context)
def _promotion_source_url(version: str, rc_num: str, dev_svn_root: str) -> str:
@@ -1148,6 +1406,62 @@ def cmd_verify(args) -> bool:
return all_valid
+def cmd_vote_email(args) -> bool:
+ """Handle 'vote-email' subcommand."""
+ _verify_project_root()
+ _validate_version(args.version)
+
+ content = _render_template(
+ "vote_email.j2",
+ _build_vote_email_context(
+ version=args.version,
+ rc_num=args.rc_num,
+ svn_url=args.svn_url,
+ pypi_url=args.pypi_url,
+ keys_url=args.keys_url,
+ changelog_summary=args.changelog_summary,
+ previous_tag=args.previous_tag,
+ ),
+ )
+ _emit_email_output(content, copy_to_clipboard=args.copy)
+ return True
+
+
+def cmd_result_email(args) -> bool:
+ """Handle 'result-email' subcommand."""
+ _verify_project_root()
+ _validate_version(args.version)
+
+ content = _generate_result_email(
+ version=args.version,
+ rc_num=args.rc_num,
+ binding_yes=args.binding_yes,
+ non_binding_yes=args.non_binding_yes,
+ abstain=args.abstain,
+ binding_no=args.binding_no,
+ non_binding_no=args.non_binding_no,
+ vote_thread_url=args.vote_thread_url,
+ )
+ _emit_email_output(content, copy_to_clipboard=args.copy)
+ return True
+
+
+def cmd_announce_email(args) -> bool:
+ """Handle 'announce-email' subcommand."""
+ _verify_project_root()
+ _validate_version(args.version)
+
+ content = _generate_announcement_email(
+ version=args.version,
+ pypi_url=args.pypi_url,
+ downloads_url=args.downloads_url,
+ changelog_summary=args.changelog_summary,
+ previous_tag=args.previous_tag,
+ )
+ _emit_email_output(content, copy_to_clipboard=args.copy)
+ return True
+
+
def cmd_all(args) -> bool:
"""Handle 'all' subcommand - run complete workflow."""
_print_section(f"Apache Burr Release Process - v{args.version}-RC{args.rc_num}")
@@ -1211,8 +1525,14 @@ def cmd_all(args) -> bool:
# ============================================================================
-def main():
- """Main entry point."""
+def _add_email_common_arguments(parser: argparse.ArgumentParser) -> None:
+ """Add common CLI arguments shared by email-generation commands."""
+ parser.add_argument("--version", required=True, help="Version (e.g., '0.41.0')")
+ parser.add_argument("--copy", action="store_true", help="Copy rendered email to clipboard")
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """Build the CLI argument parser."""
parser = argparse.ArgumentParser(description="Apache Burr Release Script (Simplified)")
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -1275,6 +1595,62 @@ def main():
verify_parser.add_argument("rc_num", help="RC number")
verify_parser.add_argument("--artifacts-dir", default="dist")
+ # vote-email subcommand
+ vote_email_parser = subparsers.add_parser("vote-email", help="Generate release vote email")
+ _add_email_common_arguments(vote_email_parser)
+ vote_email_parser.add_argument("--rc", dest="rc_num", required=True, help="RC number")
+ vote_email_parser.add_argument("--svn-url", help="Override the Apache SVN RC URL")
+ vote_email_parser.add_argument("--pypi-url", help="Override the PyPI RC package URL")
+ vote_email_parser.add_argument("--keys-url", help="Override the Apache KEYS URL")
+ vote_email_parser.add_argument(
+ "--previous-tag",
+ help="Use a specific previous release tag when building the changelog summary",
+ )
+ vote_email_parser.add_argument(
+ "--changelog-summary",
+ help="Provide a custom changelog summary instead of generating one from git history",
+ )
+
+ # result-email subcommand
+ result_email_parser = subparsers.add_parser(
+ "result-email", help="Generate release vote result email"
+ )
+ _add_email_common_arguments(result_email_parser)
+ result_email_parser.add_argument("--rc", dest="rc_num", required=True, help="RC number")
+ result_email_parser.add_argument(
+ "--binding-yes", type=int, required=True, help="Number of binding +1 votes"
+ )
+ result_email_parser.add_argument(
+ "--non-binding-yes", type=int, default=0, help="Number of non-binding +1 votes"
+ )
+ result_email_parser.add_argument("--abstain", type=int, default=0, help="Number of 0 votes")
+ result_email_parser.add_argument(
+ "--binding-no", type=int, default=0, help="Number of binding -1 votes"
+ )
+ result_email_parser.add_argument(
+ "--non-binding-no", type=int, default=0, help="Number of non-binding -1 votes"
+ )
+ result_email_parser.add_argument("--vote-thread-url", help="Link to the vote thread archive")
+
+ # announce-email subcommand
+ announce_email_parser = subparsers.add_parser(
+ "announce-email", help="Generate release announcement email"
+ )
+ _add_email_common_arguments(announce_email_parser)
+ announce_email_parser.add_argument("--pypi-url", help="Override the PyPI release URL")
+ announce_email_parser.add_argument(
+ "--downloads-url",
+ help="Override the Apache downloads URL",
+ )
+ announce_email_parser.add_argument(
+ "--previous-tag",
+ help="Use a specific previous release tag when building the changelog summary",
+ )
+ announce_email_parser.add_argument(
+ "--changelog-summary",
+ help="Provide a custom changelog summary instead of generating one from git history",
+ )
+
# all subcommand
all_parser = subparsers.add_parser("all", help="Run complete workflow")
all_parser.add_argument("version", help="Version")
@@ -1289,6 +1665,12 @@ def main():
help="Skip GPG signing (for CI). SHA512 checksum is still generated.",
)
+ return parser
+
+
+def main():
+ """Main entry point."""
+ parser = _build_parser()
args = parser.parse_args()
# Validate environment
@@ -1308,6 +1690,12 @@ def main():
success = cmd_promote(args)
elif args.command == "verify":
success = cmd_verify(args)
+ elif args.command == "vote-email":
+ success = cmd_vote_email(args)
+ elif args.command == "result-email":
+ success = cmd_result_email(args)
+ elif args.command == "announce-email":
+ success = cmd_announce_email(args)
elif args.command == "all":
success = cmd_all(args)
else:
diff --git a/scripts/templates/announce_email.j2 b/scripts/templates/announce_email.j2
new file mode 100644
index 00000000..92b1a68e
--- /dev/null
+++ b/scripts/templates/announce_email.j2
@@ -0,0 +1,40 @@
+{#
+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.
+#}
+[ANNOUNCE] Apache {{ project_display_name }} (Incubating) release {{ version }}
+
+Hi all,
+
+I'm pleased to announce the release of Apache {{ project_display_name }} {{ version }}!
+
+Apache {{ project_display_name }} is an effort undergoing incubation at The Apache Software Foundation (ASF), sponsored by the Apache Incubator. Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision making process have stabilized in a manner consistent with other successful ASF projects. While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indica [...]
+
+Apache {{ project_display_name }} makes it easy to develop applications that make
+decisions from simple Python building blocks, with explicit state machines and
+integrated tracing/debugging tooling.
+
+Release downloads:
+{{ downloads_url }}
+
+PyPI package:
+{{ pypi_url }}
+
+Highlights in this release:
+{{ changelog_summary }}
+
+Thanks to everyone who contributed!
diff --git a/scripts/templates/result_email.j2 b/scripts/templates/result_email.j2
new file mode 100644
index 00000000..97e08aac
--- /dev/null
+++ b/scripts/templates/result_email.j2
@@ -0,0 +1,39 @@
+{#
+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.
+#}
+[RESULT][VOTE] Release Apache {{ project_display_name }} (Incubating) {{ version }} RC{{ rc_num }}
+
+Hi all,
+
+Thanks everyone who participated in the vote for Release Apache {{ project_display_name }} {{ version }} RC{{ rc_num }}.
+
+Apache {{ project_display_name }} is an effort undergoing incubation at The Apache Software Foundation (ASF), sponsored by the Apache Incubator. Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision making process have stabilized in a manner consistent with other successful ASF projects. While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indica [...]
+
+The vote result is:
+
++1: {{ binding_yes }} (binding), {{ non_binding_yes }} (non-binding)
++0: {{ abstain }}
+-1: {{ binding_no }} (binding), {{ non_binding_no }} (non-binding)
+
+Vote thread:
+{{ vote_thread_url }}
+
+{{ result_outcome }}
+
+On behalf of the Apache {{ project_display_name }} PPMC,
+[Your Name]
diff --git a/scripts/templates/vote_email.j2 b/scripts/templates/vote_email.j2
new file mode 100644
index 00000000..b501352b
--- /dev/null
+++ b/scripts/templates/vote_email.j2
@@ -0,0 +1,68 @@
+{#
+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.
+#}
+[VOTE] Release Apache {{ project_display_name }} (Incubating) {{ version }} RC{{ rc_num }}
+
+Hi all,
+
+I propose the following RC to be released as the official Apache {{ project_display_name }} {{ version }} release.
+
+Apache {{ project_display_name }} is an effort undergoing incubation at The Apache Software Foundation (ASF), sponsored by the Apache Incubator. Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision making process have stabilized in a manner consistent with other successful ASF projects. While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indica [...]
+
+The artifacts for this release candidate can be found at:
+{{ svn_url }}
+
+The PyPI release candidate package is available at:
+{{ pypi_url }}
+
+The Git tag to be voted upon is:
+{{ git_tag }}
+
+Release artifacts are signed with the release manager's GPG key. The KEYS file is available at:
+{{ keys_url }}
+
+Changelog summary:
+{{ changelog_summary }}
+
+Please download, verify, and test the release candidate.
+
+For detailed step-by-step instructions on how to verify this release, please see the
+"For Voters: Verifying a Release" section in the scripts/README.md file within the
+source archive.
+
+The vote will run for a minimum of 72 hours and close no earlier than:
+{{ vote_deadline }}
+
+Please vote:
+
+[ ] +1 Release this as Apache {{ project_display_name }} {{ version }}
+[ ] +0
+[ ] -1 Do not release this package because... (reason required)
+
+Only PPMC members have binding votes, but community votes are encouraged.
+
+Checklist for reference:
+[ ] Download links are valid
+[ ] Checksums and signatures are valid
+[ ] LICENSE/NOTICE files exist
+[ ] No unexpected binary files in source
+[ ] All source files have ASF headers
+[ ] Can compile from source
+
+On behalf of the Apache {{ project_display_name }} PPMC,
+[Your Name]
diff --git a/tests/test_apache_release_email.py b/tests/test_apache_release_email.py
new file mode 100644
index 00000000..f87cb2ba
--- /dev/null
+++ b/tests/test_apache_release_email.py
@@ -0,0 +1,180 @@
+# 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 importlib.util
+from datetime import datetime, timezone
+from pathlib import Path
+from subprocess import CompletedProcess
+
+
+def _load_apache_release_module():
+ module_path = Path(__file__).resolve().parent.parent / "scripts" / "apache_release.py"
+ spec = importlib.util.spec_from_file_location("apache_release", module_path)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+ return module
+
+
+apache_release = _load_apache_release_module()
+
+
+def test_vote_email_parser_supports_flag_based_command():
+ parser = apache_release._build_parser()
+
+ args = parser.parse_args(["vote-email", "--version", "0.41.0", "--rc", "1", "--copy"])
+
+ assert args.command == "vote-email"
+ assert args.version == "0.41.0"
+ assert args.rc_num == "1"
+ assert args.copy is True
+
+
+def test_result_email_parser_requires_binding_yes():
+ parser = apache_release._build_parser()
+
+ try:
+ parser.parse_args(["result-email", "--version", "0.41.0", "--rc", "1"])
+ except SystemExit as exc:
+ assert exc.code == 2
+ else:
+ raise AssertionError("result-email should require --binding-yes")
+
+
+def test_vote_email_template_renders_expected_release_details():
+ context = apache_release._build_vote_email_context(
+ version="0.41.0",
+ rc_num="2",
+ svn_url="https://example.invalid/svn",
+ pypi_url="https://example.invalid/pypi",
+ keys_url="https://example.invalid/KEYS",
+ changelog_summary="- Added release email tooling",
+ deadline=datetime(2026, 4, 21, 12, 30, tzinfo=timezone.utc),
+ )
+
+ content = apache_release._render_template("vote_email.j2", context)
+
+ assert "[VOTE] Release Apache Burr (Incubating) 0.41.0 RC2" in content
+ assert "Apache Burr is an effort undergoing incubation" in content
+ assert "https://example.invalid/svn" in content
+ assert "https://example.invalid/pypi" in content
+ assert "https://example.invalid/KEYS" in content
+ assert "- Added release email tooling" in content
+ assert "2026-04-21 12:30 UTC" in content
+ assert "[ ] +1 Release this as Apache Burr 0.41.0" in content
+ assert "{{" not in content
+
+
+def test_result_email_template_includes_vote_tally():
+ content = apache_release._generate_result_email(
+ version="0.41.0",
+ rc_num="1",
+ binding_yes=3,
+ non_binding_yes=2,
+ abstain=1,
+ binding_no=0,
+ non_binding_no=1,
+ vote_thread_url="https://lists.apache.org/thread/example",
+ )
+
+ assert "[RESULT][VOTE] Release Apache Burr (Incubating) 0.41.0 RC1" in content
+ assert "Apache Burr is an effort undergoing incubation" in content
+ assert "+1: 3 (binding), 2 (non-binding)" in content
+ assert "+0: 1" in content
+ assert "-1: 0 (binding), 1 (non-binding)" in content
+ assert "Therefore, the release candidate has passed." in content
+ assert "https://lists.apache.org/thread/example" in content
+
+
+def test_result_email_template_supports_failed_vote_outcome():
+ content = apache_release._generate_result_email(
+ version="0.41.0",
+ rc_num="1",
+ binding_yes=2,
+ non_binding_yes=4,
+ abstain=1,
+ binding_no=2,
+ non_binding_no=0,
+ vote_thread_url="https://lists.apache.org/thread/example",
+ )
+
+ assert "Therefore, the release candidate has not passed." in content
+
+
+def test_result_email_template_ignores_non_binding_no_votes_for_pass_fail():
+ content = apache_release._generate_result_email(
+ version="0.41.0",
+ rc_num="1",
+ binding_yes=3,
+ non_binding_yes=0,
+ abstain=0,
+ binding_no=2,
+ non_binding_no=3,
+ vote_thread_url="https://lists.apache.org/thread/example",
+ )
+
+ assert "-1: 2 (binding), 3 (non-binding)" in content
+ assert "Therefore, the release candidate has passed." in content
+
+
+def test_announce_email_template_includes_release_links_and_summary():
+ content = apache_release._generate_announcement_email(
+ version="0.41.0",
+ pypi_url="https://example.invalid/pypi/0.41.0",
+ downloads_url="https://example.invalid/downloads",
+ changelog_summary="- Better release tooling",
+ )
+
+ assert "[ANNOUNCE] Apache Burr (Incubating) release 0.41.0" in content
+ assert "I'm pleased to announce the release of Apache Burr 0.41.0!" in content
+ assert "Apache Burr is an effort undergoing incubation" in content
+ assert "https://example.invalid/downloads" in content
+ assert "https://example.invalid/pypi/0.41.0" in content
+ assert "- Better release tooling" in content
+
+
+def test_emit_email_output_prints_status_to_stderr(monkeypatch, capsys):
+ monkeypatch.setattr(apache_release, "_copy_to_clipboard", lambda _content: True)
+
+ apache_release._emit_email_output("email body", copy_to_clipboard=True)
+
+ captured = capsys.readouterr()
+ assert "email body" in captured.out
+ assert "Copied email content to clipboard" in captured.err
+ assert "Copied email content to clipboard" not in captured.out
+
+
+def test_build_changelog_summary_uses_previous_release_tag(monkeypatch):
+ def fake_run(cmd, check, capture_output, text):
+ if cmd[:3] == ["git", "tag", "--list"]:
+ return CompletedProcess(cmd, 0, stdout="v0.40.2\nv0.41.0\n", stderr="")
+ if cmd[:2] == ["git", "log"]:
+ assert cmd[2] == "v0.40.2..v0.41.0"
+ return CompletedProcess(
+ cmd,
+ 0,
+ stdout="fix: tighten release docs\nfeat: add email templates\n",
+ stderr="",
+ )
+ raise AssertionError(f"Unexpected command: {cmd}")
+
+ monkeypatch.setattr(apache_release.subprocess, "run", fake_run)
+
+ summary = apache_release._build_changelog_summary("0.41.0")
+
+ assert "- fix: tighten release docs" in summary
+ assert "- feat: add email templates" in summary