diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index be8b6652a..ba1a6c5a4 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 8a87fe7ac..e84ab416e 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 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=[ diff --git a/pyproject.toml b/pyproject.toml index 3260eca3c..c2ab5d202 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 9e874cced..7c11036e4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -91,15 +91,57 @@ python scripts/apache_release.py wheel 0.41.0 0 # Wheel dist python scripts/apache_release.py upload 0.41.0 0 your_apache_id python scripts/apache_release.py upload 0.41.0 0 your_apache_id --dry-run # Test first +# Promote a voted RC from dist/dev to dist/release +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id --dry-run +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id --release-svn-root https://dist.apache.org/repos/dist/release/burr # TLP path override + # Verify artifacts locally 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. +## Promoting a voted RC + +After an RC vote passes, promote the exact voted artifacts from Apache SVN `dist/dev` into +`dist/release` with: + +```bash +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id +``` + +What it does: +- checks out the RC directory from `dist/dev` to validate the expected source archive, + sdist, wheel, and matching `.asc` / `.sha512` files +- refuses to continue if the target release directory already exists +- copies the voted RC directory server-side into a new per-version release directory + (e.g. `dist/release/incubator/burr/0.41.0`) with a single atomic `svn cp` commit +- leaves any existing release directories and the shared `KEYS` file untouched (additive) +- prints the final PyPI upload command for the sdist and wheel + +Use `--dry-run` to preview the actions without committing: + +```bash +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id --dry-run +``` + +For post-incubation path changes, override the default SVN roots: + +```bash +python scripts/apache_release.py promote 0.41.0-RC0 your_apache_id \ + --dev-svn-root https://dist.apache.org/repos/dist/dev/burr \ + --release-svn-root https://dist.apache.org/repos/dist/release/burr +``` + ## For Voters: Verifying a Release If you're voting on a release, follow these steps to verify the release candidate: diff --git a/scripts/apache_release.py b/scripts/apache_release.py index fd5e2fbcc..1d823fe1c 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -39,12 +39,23 @@ 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}" +) +RC_LABEL_PATTERN = re.compile( + r"^(?P\d+\.\d+\.\d+)(?:-incubating)?-RC(?P\d+)$", re.IGNORECASE +) # Required examples for wheel (from pyproject.toml) REQUIRED_EXAMPLES = [ @@ -56,7 +67,6 @@ "hello-world-counter", ] - # ============================================================================ # Utility Functions # ============================================================================ @@ -121,6 +131,265 @@ 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()) + if not match: + _fail("Invalid RC label. Expected format like '0.42.0-RC1' " "or '0.42.0-incubating-RC1'.") + return match.group("version"), match.group("rc_num") + + # ============================================================================ # Environment Validation # ============================================================================ @@ -138,8 +407,12 @@ def _validate_environment_for_command(args) -> None: "sdist": ["git", "gpg", "flit"], "wheel": ["git", "gpg", "flit", "node", "npm", "twine"], "upload": ["git", "gpg", "svn"], + "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"])) @@ -789,50 +1062,183 @@ 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}" + """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) - return f"""[VOTE] Release Apache {PROJECT_SHORT_NAME} {version_with_incubating} (RC{rc_num}) -Hi all, +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) -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} +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) -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 +def _promotion_source_url(version: str, rc_num: str, dev_svn_root: str) -> str: + """Return the SVN URL for a voted RC in dist/dev.""" + return f"{dev_svn_root}/{version}-incubating-RC{rc_num}" -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. +def _promotion_target_url(version: str, release_svn_root: str) -> str: + """Return the SVN URL for the final per-version release directory. -The vote will run for a minimum of 72 hours. -Please vote: + Releases are published under a per-version subdirectory + (e.g. dist/release/incubator/burr/0.42.0), alongside any existing + releases and the shared KEYS file at the project root. + """ + return f"{release_svn_root}/{version}" -[ ] +1 Release this package as Apache {PROJECT_SHORT_NAME} {version_with_incubating} -[ ] +0 No opinion -[ ] -1 Do not release this package because... (reason required) -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 +def _promotion_commit_message(version: str, rc_num: str) -> str: + """Return the SVN commit message for a promotion.""" + return f"Promote Apache Burr {version}-incubating RC{rc_num} to release" -On behalf of the Apache {PROJECT_SHORT_NAME} PPMC, -[Your Name] -""" + +def _expected_promotion_artifact_patterns(version: str) -> dict[str, str]: + """Return the required artifact patterns for a final release promotion.""" + return { + "source_archive": f"apache-burr-{version}-incubating-src.tar.gz", + "sdist": f"apache-burr-{version}-incubating-sdist.tar.gz", + "wheel": f"apache_burr-{version}-*.whl", + } + + +def _find_single_glob_match(directory: str, pattern: str, description: str) -> str: + matches = sorted(glob.glob(os.path.join(directory, pattern))) + if not matches: + _fail(f"Missing required {description}: {pattern}") + if len(matches) > 1: + names = ", ".join(os.path.basename(match) for match in matches) + _fail(f"Expected exactly one {description} for pattern {pattern}, found: {names}") + return matches[0] + + +def _validate_promotion_artifacts(rc_checkout_dir: str, version: str) -> list[str]: + """Validate the expected release artifacts exist in the RC checkout.""" + artifacts: list[str] = [] + patterns = _expected_promotion_artifact_patterns(version) + + source_archive = _find_single_glob_match( + rc_checkout_dir, patterns["source_archive"], "source archive" + ) + sdist = _find_single_glob_match(rc_checkout_dir, patterns["sdist"], "source distribution") + wheel = _find_single_glob_match(rc_checkout_dir, patterns["wheel"], "wheel") + + for artifact_path in [source_archive, sdist, wheel]: + artifacts.append(artifact_path) + for suffix in [".asc", ".sha512"]: + companion_path = f"{artifact_path}{suffix}" + if not os.path.exists(companion_path): + _fail(f"Missing required companion artifact: {os.path.basename(companion_path)}") + artifacts.append(companion_path) + + return sorted(artifacts) + + +def _twine_upload_command(promoted_artifacts: list[str]) -> str: + """Return the PyPI upload command for the final release artifacts.""" + upload_candidates = [ + artifact + for artifact in promoted_artifacts + if artifact.endswith(".whl") or artifact.endswith("-incubating-sdist.tar.gz") + ] + upload_names = " ".join(sorted(os.path.basename(artifact) for artifact in upload_candidates)) + return f"twine upload {upload_names}" + + +def _svn_checkout(url: str, checkout_dir: str) -> None: + """Check out an SVN URL into a local directory.""" + _run_command( + ["svn", "checkout", url, checkout_dir], + description=f"Checking out SVN path: {url}", + error_message=f"SVN checkout failed for {url}", + success_message="SVN checkout completed", + ) + + +def _svn_target_exists(url: str) -> bool: + """Return True if an SVN URL already exists in the repository.""" + result = subprocess.run( + ["svn", "info", url], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def _promote_with_server_copy( + source_url: str, + target_url: str, + message: str, + apache_id: str, + dry_run: bool = False, +) -> bool: + """Promote a voted RC by copying it server-side into the release tree. + + A single ``svn cp /`` is atomic: it copies the + voted RC directory (artifacts plus their .asc / .sha512 companions) into a + new per-version release directory in one commit, without downloading the + artifacts. Existing release directories and the shared KEYS file are left + untouched, matching the additive layout used in dist/release. + """ + command = [ + "svn", + "cp", + source_url, + target_url, + "-m", + message, + "--username", + apache_id, + ] + if dry_run: + print(f" [DRY RUN] Would run: {' '.join(command)}") + return True + + _run_command( + command, + description="Promoting RC to release via server-side copy...", + error_message="SVN server-side copy failed for promotion", + success_message="Release promoted", + capture_output=False, + ) + return True # ============================================================================ @@ -926,6 +1332,55 @@ def cmd_upload(args) -> bool: return True +def cmd_promote(args) -> bool: + """Handle 'promote' subcommand.""" + _print_section(f"Promoting Release Candidate - {args.rc_label}") + _verify_project_root() + + version, rc_num = _parse_rc_label(args.rc_label) + source_url = _promotion_source_url(version, rc_num, args.dev_svn_root) + target_url = _promotion_target_url(version, args.release_svn_root) + + print(f"Source RC URL: {source_url}") + print(f"Release URL: {target_url}") + if args.dry_run: + print("\n*** DRY RUN MODE ***") + + if _svn_target_exists(target_url): + _fail( + f"Release path already exists: {target_url}\n" + "Refusing to overwrite an already-promoted release." + ) + + with tempfile.TemporaryDirectory(prefix="burr-promote-") as temp_dir: + rc_checkout_dir = os.path.join(temp_dir, "rc") + _svn_checkout(source_url, rc_checkout_dir) + + print("\nValidating expected artifacts...") + validated_artifacts = _validate_promotion_artifacts(rc_checkout_dir, version) + for artifact in validated_artifacts: + print(f" ✓ {os.path.basename(artifact)}") + + print("\nPromoting RC into release...") + _promote_with_server_copy( + source_url, + target_url, + _promotion_commit_message(version, rc_num), + args.apache_id, + dry_run=args.dry_run, + ) + + print("\nPromotion summary:") + print(f" Release path: {target_url}") + for artifact in validated_artifacts: + print(f" - {os.path.basename(artifact)}") + + print("\nPyPI upload command:") + print(f" {_twine_upload_command(validated_artifacts)}") + + return True + + def cmd_verify(args) -> bool: """Handle 'verify' subcommand.""" _print_section(f"Verifying Artifacts - v{args.version}-RC{args.rc_num}") @@ -951,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}") @@ -1014,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) @@ -1052,12 +1569,88 @@ def main(): upload_parser.add_argument("--artifacts-dir", default="dist") upload_parser.add_argument("--dry-run", action="store_true") + # promote subcommand + promote_parser = subparsers.add_parser( + "promote", help="Promote a voted RC from dist/dev to dist/release" + ) + promote_parser.add_argument( + "rc_label", help="Release candidate label, e.g. '0.42.0-RC1' or '0.42.0-incubating-RC1'" + ) + promote_parser.add_argument("apache_id", help="Apache ID") + promote_parser.add_argument("--dry-run", action="store_true") + promote_parser.add_argument( + "--dev-svn-root", + default=DEFAULT_DEV_SVN_ROOT, + help="SVN root for RC artifacts in dist/dev", + ) + promote_parser.add_argument( + "--release-svn-root", + default=DEFAULT_RELEASE_SVN_ROOT, + help="SVN root for promoted artifacts in dist/release", + ) + # verify subcommand verify_parser = subparsers.add_parser("verify", help="Verify artifacts") verify_parser.add_argument("version", help="Version") 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") @@ -1072,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 @@ -1087,8 +1686,16 @@ def main(): success = cmd_wheel(args) elif args.command == "upload": success = cmd_upload(args) + elif args.command == "promote": + 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 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..97e08aac4 --- /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: {{ 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 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.py b/tests/test_apache_release.py new file mode 100644 index 000000000..ac0e48245 --- /dev/null +++ b/tests/test_apache_release.py @@ -0,0 +1,171 @@ +# 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 +import sys +from argparse import Namespace +from pathlib import Path + +import pytest + + +def _load_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 + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +release = _load_release_module() + + +def _write_artifact_set(directory: Path, version: str, wheel_name: str = None) -> None: + wheel_name = wheel_name or f"apache_burr-{version}-py3-none-any.whl" + artifact_names = [ + f"apache-burr-{version}-incubating-src.tar.gz", + f"apache-burr-{version}-incubating-sdist.tar.gz", + wheel_name, + ] + for artifact_name in artifact_names: + artifact_path = directory / artifact_name + artifact_path.write_bytes(b"artifact") + artifact_path.with_name(f"{artifact_name}.asc").write_text("sig", encoding="utf-8") + artifact_path.with_name(f"{artifact_name}.sha512").write_text("sha", encoding="utf-8") + + +def test_parse_rc_label_accepts_supported_formats(): + assert release._parse_rc_label("0.42.0-RC1") == ("0.42.0", "1") + assert release._parse_rc_label("0.42.0-incubating-RC1") == ("0.42.0", "1") + + +def test_parse_rc_label_rejects_invalid_format(): + with pytest.raises(SystemExit): + release._parse_rc_label("0.42.0") + + +def test_validate_promotion_artifacts_requires_expected_set(tmp_path): + _write_artifact_set(tmp_path, "0.42.0") + + artifacts = release._validate_promotion_artifacts(str(tmp_path), "0.42.0") + + assert len(artifacts) == 9 + assert any(path.endswith("apache-burr-0.42.0-incubating-src.tar.gz") for path in artifacts) + assert any(path.endswith("apache-burr-0.42.0-incubating-sdist.tar.gz") for path in artifacts) + assert any(path.endswith("apache_burr-0.42.0-py3-none-any.whl") for path in artifacts) + + +def test_validate_promotion_artifacts_fails_when_companion_missing(tmp_path): + _write_artifact_set(tmp_path, "0.42.0") + (tmp_path / "apache-burr-0.42.0-incubating-src.tar.gz.asc").unlink() + + with pytest.raises(SystemExit): + release._validate_promotion_artifacts(str(tmp_path), "0.42.0") + + +def test_promotion_target_url_appends_version_subdir(): + assert ( + release._promotion_target_url( + "0.42.0", "https://dist.apache.org/repos/dist/release/incubator/burr" + ) + == "https://dist.apache.org/repos/dist/release/incubator/burr/0.42.0" + ) + + +def test_twine_upload_command_includes_only_sdist_and_wheel(): + command = release._twine_upload_command( + [ + "apache-burr-0.42.0-incubating-src.tar.gz", + "apache-burr-0.42.0-incubating-src.tar.gz.asc", + "apache-burr-0.42.0-incubating-sdist.tar.gz", + "apache_burr-0.42.0-py3-none-any.whl", + ] + ) + + assert command == ( + "twine upload apache-burr-0.42.0-incubating-sdist.tar.gz " + "apache_burr-0.42.0-py3-none-any.whl" + ) + + +def test_cmd_promote_rejects_already_promoted_release(monkeypatch): + monkeypatch.setattr(release, "_verify_project_root", lambda: None) + monkeypatch.setattr(release, "_svn_target_exists", lambda url: True) + + args = Namespace( + rc_label="0.42.0-RC1", + apache_id="hari", + dry_run=False, + dev_svn_root="https://dist.apache.org/repos/dist/dev/incubator/burr", + release_svn_root="https://dist.apache.org/repos/dist/release/incubator/burr", + ) + + with pytest.raises(SystemExit): + release.cmd_promote(args) + + +def test_cmd_promote_dry_run_uses_server_copy_without_committing(monkeypatch, tmp_path): + calls = {"checkout": [], "promote": None} + + class _TempDir: + def __enter__(self): + return str(tmp_path) + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_checkout(url: str, checkout_dir: str): + calls["checkout"].append((url, checkout_dir)) + Path(checkout_dir).mkdir(parents=True, exist_ok=True) + + def fake_validate(rc_checkout_dir: str, version: str): + assert version == "0.42.0" + return [ + f"{rc_checkout_dir}/apache-burr-0.42.0-incubating-sdist.tar.gz", + f"{rc_checkout_dir}/apache_burr-0.42.0-py3-none-any.whl", + ] + + def fake_promote(source_url, target_url, message, apache_id, dry_run=False): + calls["promote"] = (source_url, target_url, message, apache_id, dry_run) + return True + + monkeypatch.setattr(release, "_verify_project_root", lambda: None) + monkeypatch.setattr(release.tempfile, "TemporaryDirectory", lambda prefix=None: _TempDir()) + monkeypatch.setattr(release, "_svn_target_exists", lambda url: False) + monkeypatch.setattr(release, "_svn_checkout", fake_checkout) + monkeypatch.setattr(release, "_validate_promotion_artifacts", fake_validate) + monkeypatch.setattr(release, "_promote_with_server_copy", fake_promote) + + args = Namespace( + rc_label="0.42.0-RC1", + apache_id="hari", + dry_run=True, + dev_svn_root="https://dist.apache.org/repos/dist/dev/incubator/burr", + release_svn_root="https://dist.apache.org/repos/dist/release/incubator/burr", + ) + + assert release.cmd_promote(args) is True + # only the RC is checked out; the release tree is never downloaded + assert len(calls["checkout"]) == 1 + assert calls["checkout"][0][0].endswith("/0.42.0-incubating-RC1") + source_url, target_url, message, apache_id, dry_run = calls["promote"] + assert source_url.endswith("/0.42.0-incubating-RC1") + assert target_url == "https://dist.apache.org/repos/dist/release/incubator/burr/0.42.0" + assert apache_id == "hari" + assert dry_run is True diff --git a/tests/test_apache_release_email.py b/tests/test_apache_release_email.py new file mode 100644 index 000000000..f87cb2ba1 --- /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