From a3f0563c6ae71b348594d01a9d2917007344f43f Mon Sep 17 00:00:00 2001 From: Hari Charan Panjwani Date: Wed, 3 Jun 2026 20:37:34 -0700 Subject: [PATCH 1/2] ci: add RC promotion command (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add RC promotion command * ci: preserve KEYS during RC promotion * ci: apply black formatting to promote command and tests black --line-length=100 reformats scripts/apache_release.py and tests/test_apache_release.py; the pre-commit black hook was failing. Signed-off-by: André Ahlert * ci: fix RC promotion to additive per-version release layout via svn cp The promote command copied artifacts flat into the dist/release project root and svn-rm'd every existing entry (except KEYS). But dist/release publishes each release under a per-version subdirectory (e.g. dist/release/incubator/burr/0.42.0) and keeps prior releases alongside KEYS. The old flow would have dumped artifacts in the wrong place and deleted already-published releases (0.41.0, 0.42.0). Promote now performs a single server-side 'svn cp /': atomic, no local download of the release tree, and additive. Existing release directories and KEYS are untouched by construction. The command refuses to run if the target version directory already exists. The local RC checkout is kept only to validate the expected artifacts and their .asc/.sha512 companions before promotion. Drops the now-unnecessary flat-copy/remove/commit helpers and the dead RC-suffix rename (release artifact names never carry the RC number; it lives in the dev directory name). README and tests updated to match. Note: the svn workflow is unit-tested with mocked svn but not verified end-to-end against live ASF dist. Signed-off-by: André Ahlert --------- Signed-off-by: André Ahlert Co-authored-by: André Ahlert --- scripts/README.md | 37 ++++++ scripts/apache_release.py | 219 +++++++++++++++++++++++++++++++++++ tests/test_apache_release.py | 171 +++++++++++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 tests/test_apache_release.py diff --git a/scripts/README.md b/scripts/README.md index 9e874cced..522cf0735 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -91,6 +91,11 @@ 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 @@ -100,6 +105,38 @@ python scripts/apache_release.py all 0.41.0 0 your_apache_id --no-upload 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..49ea80c72 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -45,6 +45,13 @@ PROJECT_SHORT_NAME = "burr" VERSION_FILE = "pyproject.toml" VERSION_PATTERN = r'version\s*=\s*"(\d+\.\d+\.\d+)"' +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 = [ @@ -121,6 +128,14 @@ def _run_command( _fail(f"{error_message}{error_detail}") +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,6 +153,7 @@ 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"], } @@ -835,6 +851,138 @@ def _generate_vote_email(version: str, rc_num: str, svn_url: str) -> str: """ +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}" + + +def _promotion_target_url(version: str, release_svn_root: str) -> str: + """Return the SVN URL for the final per-version release directory. + + 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}" + + +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" + + +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 + + # ============================================================================ # Command Handlers # ============================================================================ @@ -926,6 +1074,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}") @@ -1052,6 +1249,26 @@ 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") @@ -1087,6 +1304,8 @@ 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 == "all": 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 From c2b752ed53553c0ac8f5bd24f5309cd4a466827e Mon Sep 17 00:00:00 2001 From: Shabbir Hussain <72.shabbir@gmail.com> Date: Wed, 3 Jun 2026 21:11:26 -0700 Subject: [PATCH 2/2] 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(-) 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/.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 522cf0735..7c11036e4 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 49ea80c72..1d823fe1c 100644 --- a/scripts/apache_release.py +++ b/scripts/apache_release.py @@ -39,12 +39,16 @@ 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 @@ "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 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_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