From a8941b2e94a41e99a2006c91caa8a957a1c01b6f Mon Sep 17 00:00:00 2001 From: Shabbir Hussain <72.shabbir@gmail.com> Date: Sun, 19 Apr 2026 12:30:00 -0700 Subject: [PATCH 1/3] feat: add release email generation tooling --- .gitignore | 1 + pyproject.toml | 4 + scripts/README.md | 5 + scripts/apache_release.py | 468 +++++++++++++++++++++++++--- scripts/templates/announce_email.j2 | 40 +++ scripts/templates/result_email.j2 | 39 +++ scripts/templates/vote_email.j2 | 68 ++++ tests/test_apache_release_email.py | 162 ++++++++++ 8 files changed, 742 insertions(+), 45 deletions(-) create mode 100644 scripts/templates/announce_email.j2 create mode 100644 scripts/templates/result_email.j2 create mode 100644 scripts/templates/vote_email.j2 create mode 100644 tests/test_apache_release_email.py diff --git a/.gitignore b/.gitignore index d9714f671..eb3b54d41 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/pyproject.toml b/pyproject.toml index 71b74dfa3..c2f122095 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 3a07b126b..cd3099371 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -96,6 +96,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 +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 0c355ba62..6a729b7f2 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -38,12 +38,16 @@ import shutil import subprocess import sys -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}/" # Required examples for wheel (from pyproject.toml) REQUIRED_EXAMPLES = [ @@ -54,7 +58,6 @@ "deep-researcher", ] - # ============================================================================ # Utility Functions # ============================================================================ @@ -119,6 +122,255 @@ 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, + no_votes: 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 > no_votes + 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), + "no_votes": str(no_votes), + "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}/" + + # ============================================================================ # Environment Validation # ============================================================================ @@ -138,9 +390,12 @@ def _validate_environment_for_command(args) -> None: "upload": ["git", "gpg", "svn"], "all": ["git", "gpg", "flit", "node", "npm", "svn", "twine"], "verify": ["git", "gpg", "twine"], + "vote-email": ["git"], + "result-email": [], + "announce-email": ["git"], } - required_tools = command_requirements.get(args.command, ["git", "gpg"]) + required_tools = list(command_requirements.get(args.command, [])) # Check for RAT if needed if hasattr(args, "check_licenses") or hasattr(args, "check_licenses_report"): @@ -720,50 +975,49 @@ 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 - -Please download, verify, and test the release candidate. + """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) -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, + no_votes: 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, + no_votes=no_votes, + 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) # ============================================================================ @@ -877,6 +1131,61 @@ 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, + no_votes=args.no_votes, + 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}") @@ -935,8 +1244,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) @@ -972,6 +1287,57 @@ 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("--no-votes", type=int, default=0, help="Number of -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") @@ -981,6 +1347,12 @@ def main(): all_parser.add_argument("--dry-run", action="store_true") all_parser.add_argument("--no-upload", action="store_true") + return parser + + +def main(): + """Main entry point.""" + parser = _build_parser() args = parser.parse_args() # Validate environment @@ -998,6 +1370,12 @@ def main(): success = cmd_upload(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 000000000..92b1a68e4 --- /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 indicate that the project has yet to be fully endorsed by the ASF. + +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 000000000..e6331dd5d --- /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 indicate that the project has yet to be fully endorsed by the ASF. + +The vote result is: + ++1: {{ binding_yes }} (binding), {{ non_binding_yes }} (non-binding) ++0: {{ abstain }} +-1: {{ no_votes }} + +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 000000000..b501352b0 --- /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 indicate that the project has yet to be fully endorsed by the ASF. + +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 000000000..a0d2dba1d --- /dev/null +++ b/tests/test_apache_release_email.py @@ -0,0 +1,162 @@ +# 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, + no_votes=0, + 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" 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, + no_votes=2, + vote_thread_url="https://lists.apache.org/thread/example", + ) + + assert "Therefore, the release candidate has not 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 From ee0bc8b1ea43353256351e50483fc9f1af09a837 Mon Sep 17 00:00:00 2001 From: Shabbir Hussain <72.shabbir@gmail.com> Date: Thu, 23 Apr 2026 20:00:23 -0700 Subject: [PATCH 2/3] fix: handle binding no votes in result email --- scripts/README.md | 2 +- scripts/apache_release.py | 24 +++++++++++++++++------- scripts/templates/result_email.j2 | 2 +- tests/test_apache_release_email.py | 24 +++++++++++++++++++++--- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index cd3099371..ac604a350 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -99,7 +99,7 @@ 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 +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 ``` diff --git a/scripts/apache_release.py b/scripts/apache_release.py index 6a729b7f2..71d13680a 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -309,11 +309,12 @@ def _build_result_email_context( binding_yes: int, non_binding_yes: int, abstain: int, - no_votes: 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 > no_votes + release_passed = binding_yes >= 3 and binding_yes > binding_no return { "project_short_name": PROJECT_SHORT_NAME, "project_display_name": PROJECT_SHORT_NAME.capitalize(), @@ -323,7 +324,8 @@ def _build_result_email_context( "binding_yes": str(binding_yes), "non_binding_yes": str(non_binding_yes), "abstain": str(abstain), - "no_votes": str(no_votes), + "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." @@ -986,7 +988,8 @@ def _generate_result_email( binding_yes: int, non_binding_yes: int, abstain: int, - no_votes: int, + binding_no: int, + non_binding_no: int, vote_thread_url: Optional[str] = None, ) -> str: """Generate [RESULT] email from template.""" @@ -996,7 +999,8 @@ def _generate_result_email( binding_yes=binding_yes, non_binding_yes=non_binding_yes, abstain=abstain, - no_votes=no_votes, + binding_no=binding_no, + non_binding_no=non_binding_no, vote_thread_url=vote_thread_url, ) return _render_template("result_email.j2", context) @@ -1163,7 +1167,8 @@ def cmd_result_email(args) -> bool: binding_yes=args.binding_yes, non_binding_yes=args.non_binding_yes, abstain=args.abstain, - no_votes=args.no_votes, + 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) @@ -1316,7 +1321,12 @@ def _build_parser() -> argparse.ArgumentParser: "--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("--no-votes", type=int, default=0, help="Number of -1 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 diff --git a/scripts/templates/result_email.j2 b/scripts/templates/result_email.j2 index e6331dd5d..97e08aac4 100644 --- a/scripts/templates/result_email.j2 +++ b/scripts/templates/result_email.j2 @@ -28,7 +28,7 @@ The vote result is: +1: {{ binding_yes }} (binding), {{ non_binding_yes }} (non-binding) +0: {{ abstain }} --1: {{ no_votes }} +-1: {{ binding_no }} (binding), {{ non_binding_no }} (non-binding) Vote thread: {{ vote_thread_url }} diff --git a/tests/test_apache_release_email.py b/tests/test_apache_release_email.py index a0d2dba1d..f87cb2ba1 100644 --- a/tests/test_apache_release_email.py +++ b/tests/test_apache_release_email.py @@ -86,7 +86,8 @@ def test_result_email_template_includes_vote_tally(): binding_yes=3, non_binding_yes=2, abstain=1, - no_votes=0, + binding_no=0, + non_binding_no=1, vote_thread_url="https://lists.apache.org/thread/example", ) @@ -94,7 +95,7 @@ def test_result_email_template_includes_vote_tally(): 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" 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 @@ -106,13 +107,30 @@ def test_result_email_template_supports_failed_vote_outcome(): binding_yes=2, non_binding_yes=4, abstain=1, - no_votes=2, + 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", From 1848e65e35503fd76615d1d9f5389c9faf7a2711 Mon Sep 17 00:00:00 2001 From: Shabbir Hussain <72.shabbir@gmail.com> Date: Tue, 28 Apr 2026 01:28:45 -0700 Subject: [PATCH 3/3] fix: make adaptive crag example python 3.10 compatible --- examples/adaptive-crag/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/adaptive-crag/application.py b/examples/adaptive-crag/application.py index 3b08c7046..912402e12 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=[