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