| #!/usr/bin/env python3 |
| # 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. |
| |
| """ |
| Apache Burr Release Script (SIMPLIFIED VERSION) |
| |
| This script automates the Apache release process: |
| 1. Create git archive (voting artifact) |
| 2. Build source distribution (sdist) |
| 3. Build wheel |
| 4. Upload to Apache SVN |
| |
| Usage: |
| python scripts/apache_release_simplified.py all 0.41.0 0 myid |
| python scripts/apache_release_simplified.py wheel 0.41.0 0 |
| """ |
| |
| import argparse |
| import glob |
| import hashlib |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| 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<version>\d+\.\d+\.\d+)(?:-incubating)?-RC(?P<rc_num>\d+)$", re.IGNORECASE |
| ) |
| |
| # Required examples for wheel (from pyproject.toml) |
| REQUIRED_EXAMPLES = [ |
| "__init__.py", |
| "email-assistant", |
| "multi-modal-chatbot", |
| "streaming-fastapi", |
| "deep-researcher", |
| "hello-world-counter", |
| ] |
| |
| # ============================================================================ |
| # Utility Functions |
| # ============================================================================ |
| |
| |
| def _fail(message: str) -> NoReturn: |
| """Print error message and exit.""" |
| print(f"\n❌ {message}") |
| sys.exit(1) |
| |
| |
| def _print_section(title: str) -> None: |
| """Print a formatted section header.""" |
| print("\n" + "=" * 80) |
| print(f" {title}") |
| print("=" * 80 + "\n") |
| |
| |
| def _print_step(step_num: int, total: int, description: str) -> None: |
| """Print a formatted step header.""" |
| print(f"\n[Step {step_num}/{total}] {description}") |
| print("-" * 80) |
| |
| |
| def _run_command( |
| cmd: list[str], |
| description: str, |
| error_message: str, |
| success_message: Optional[str] = None, |
| capture_output: bool = True, |
| **kwargs, |
| ) -> subprocess.CompletedProcess: |
| """Run a subprocess command with consistent error handling and output. |
| |
| Args: |
| cmd: Command and arguments as list |
| description: What we're doing (printed before running) |
| error_message: Error message prefix if command fails |
| success_message: Optional success message (printed after if provided) |
| capture_output: Whether to capture stdout/stderr (default True) |
| **kwargs: Additional arguments to pass to subprocess.run |
| |
| Returns: |
| CompletedProcess instance |
| """ |
| if description: |
| print(f" {description}") |
| |
| try: |
| result = subprocess.run( |
| cmd, |
| check=True, |
| capture_output=capture_output, |
| text=True, |
| **kwargs, |
| ) |
| if success_message: |
| print(f" ✓ {success_message}") |
| return result |
| except subprocess.CalledProcessError as e: |
| error_detail = f": {e.stderr}" if capture_output and e.stderr else "" |
| _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 |
| # ============================================================================ |
| |
| |
| def _validate_environment_for_command(args) -> None: |
| """Validate required tools for the requested command.""" |
| print("\n" + "=" * 80) |
| print(" Environment Validation") |
| print("=" * 80 + "\n") |
| |
| # Define required tools for each command |
| command_requirements = { |
| "archive": ["git", "gpg"], |
| "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"])) |
| |
| # Drop gpg if user opted out of signing |
| if getattr(args, "skip_signing", False) and "gpg" in required_tools: |
| required_tools.remove("gpg") |
| |
| # Drop svn if user opted out of upload (svn is only used for upload) |
| if getattr(args, "no_upload", False) and "svn" in required_tools: |
| required_tools.remove("svn") |
| |
| # Check for RAT if needed |
| if hasattr(args, "check_licenses") or hasattr(args, "check_licenses_report"): |
| if getattr(args, "check_licenses", False) or getattr(args, "check_licenses_report", False): |
| required_tools.append("java") |
| if not getattr(args, "rat_jar", None): |
| _fail("--rat-jar is required when using --check-licenses") |
| |
| # Check each tool |
| missing_tools = [] |
| print("Checking required tools:") |
| |
| for tool in required_tools: |
| if shutil.which(tool) is None: |
| missing_tools.append(tool) |
| print(f" ✗ '{tool}' not found") |
| else: |
| print(f" ✓ '{tool}' found") |
| |
| if missing_tools: |
| print("\n❌ Missing required tools:") |
| for tool in missing_tools: |
| if tool == "flit": |
| print(f" • {tool}: Install with 'pip install flit'") |
| elif tool == "twine": |
| print(f" • {tool}: Install with 'pip install twine'") |
| elif tool in ["node", "npm"]: |
| print(f" • {tool}: Install from https://nodejs.org/") |
| else: |
| print(f" • {tool}") |
| sys.exit(1) |
| |
| print("\n✓ All required tools are available\n") |
| |
| |
| # ============================================================================ |
| # Prerequisites |
| # ============================================================================ |
| |
| |
| def _verify_project_root() -> bool: |
| """Verify script is running from project root.""" |
| if not os.path.exists("pyproject.toml"): |
| _fail("pyproject.toml not found. Please run from project root.") |
| return True |
| |
| |
| def _get_version_from_file(file_path: str) -> str: |
| """Extract version from pyproject.toml.""" |
| with open(file_path, encoding="utf-8") as f: |
| content = f.read() |
| match = re.search(VERSION_PATTERN, content) |
| if match: |
| return match.group(1) |
| _fail(f"Could not find version in {file_path}") |
| |
| |
| def _validate_version(requested_version: str) -> bool: |
| """Validate that requested version matches pyproject.toml.""" |
| current_version = _get_version_from_file(VERSION_FILE) |
| if current_version != requested_version: |
| _fail( |
| f"Version mismatch!\n" |
| f" Requested: {requested_version}\n" |
| f" In {VERSION_FILE}: {current_version}\n" |
| f"Please update {VERSION_FILE} to {requested_version} first." |
| ) |
| print(f"✓ Version validated: {requested_version}\n") |
| return True |
| |
| |
| def _check_git_working_tree() -> None: |
| """Check git working tree status and warn if dirty.""" |
| try: |
| dirty = ( |
| subprocess.check_output(["git", "status", "--porcelain"], stderr=subprocess.DEVNULL) |
| .decode() |
| .strip() |
| ) |
| if dirty: |
| print("⚠️ Warning: Git working tree has uncommitted changes:") |
| for line in dirty.splitlines()[:10]: |
| print(f" {line}") |
| if len(dirty.splitlines()) > 10: |
| print(f" ... and {len(dirty.splitlines()) - 10} more files") |
| print() |
| except subprocess.CalledProcessError: |
| pass |
| |
| |
| # ============================================================================ |
| # Signing and Verification |
| # ============================================================================ |
| |
| |
| def _checksum_artifact(artifact_path: str) -> str: |
| """Create SHA512 checksum for artifact in the standard ``sha512sum`` layout. |
| |
| The file is written as ``<digest> <filename>\n`` so that voters can verify |
| with the standard ``sha512sum -c <file>.sha512`` recipe without having to |
| splice the filename in by hand. |
| """ |
| checksum_path = f"{artifact_path}.sha512" |
| sha512_hash = hashlib.sha512() |
| with open(artifact_path, "rb") as f: |
| while chunk := f.read(65536): |
| sha512_hash.update(chunk) |
| artifact_filename = os.path.basename(artifact_path) |
| with open(checksum_path, "w", encoding="utf-8") as f: |
| f.write(f"{sha512_hash.hexdigest()} {artifact_filename}\n") |
| print(f" ✓ Created SHA512 checksum: {checksum_path}") |
| return checksum_path |
| |
| |
| def _sign_artifact(artifact_path: str, skip_signing: bool = False) -> tuple[Optional[str], str]: |
| """Sign artifact with GPG (unless skipped) and create SHA512 checksum.""" |
| signature_path: Optional[str] = None |
| if not skip_signing: |
| signature_path = f"{artifact_path}.asc" |
| _run_command( |
| ["gpg", "--armor", "--output", signature_path, "--detach-sig", artifact_path], |
| description="", |
| error_message="Error signing artifact", |
| capture_output=False, |
| ) |
| print(f" ✓ Created GPG signature: {signature_path}") |
| else: |
| print(" ⊘ Skipping GPG signature (--skip-signing)") |
| |
| checksum_path = _checksum_artifact(artifact_path) |
| return (signature_path, checksum_path) |
| |
| |
| def _verify_artifact_signature(artifact_path: str, signature_path: str) -> bool: |
| """Verify GPG signature of artifact.""" |
| if not os.path.exists(signature_path): |
| print(f" ✗ Signature file not found: {signature_path}") |
| return False |
| |
| try: |
| result = subprocess.run( |
| ["gpg", "--verify", signature_path, artifact_path], |
| capture_output=True, |
| check=False, |
| ) |
| if result.returncode == 0: |
| print(" ✓ GPG signature is valid") |
| return True |
| else: |
| print(" ✗ GPG signature verification failed") |
| return False |
| except subprocess.CalledProcessError: |
| return False |
| |
| |
| def _verify_artifact_checksum(artifact_path: str, checksum_path: str) -> bool: |
| """Verify SHA512 checksum of artifact.""" |
| if not os.path.exists(checksum_path): |
| print(f" ✗ Checksum file not found: {checksum_path}") |
| return False |
| |
| # Read expected checksum |
| with open(checksum_path, "r", encoding="utf-8") as f: |
| expected_checksum = f.read().strip().split()[0] |
| |
| # Calculate actual checksum |
| sha512_hash = hashlib.sha512() |
| with open(artifact_path, "rb") as f: |
| while chunk := f.read(65536): |
| sha512_hash.update(chunk) |
| |
| actual_checksum = sha512_hash.hexdigest() |
| |
| if actual_checksum == expected_checksum: |
| print(" ✓ SHA512 checksum is valid") |
| return True |
| else: |
| print(" ✗ SHA512 checksum mismatch!") |
| return False |
| |
| |
| def _verify_artifact_complete(artifact_path: str, skip_signing: bool = False) -> bool: |
| """Verify artifact and its signature/checksum files.""" |
| print(f"\nVerifying artifact: {os.path.basename(artifact_path)}") |
| |
| if not os.path.exists(artifact_path): |
| print(f" ✗ Artifact not found: {artifact_path}") |
| return False |
| |
| # Verify signature (unless skipped) and checksum |
| checksum_path = f"{artifact_path}.sha512" |
| checksum_valid = _verify_artifact_checksum(artifact_path, checksum_path) |
| |
| if skip_signing: |
| if checksum_valid: |
| print(f" ✓ Checks passed for {os.path.basename(artifact_path)} (signing skipped)\n") |
| return True |
| return False |
| |
| signature_path = f"{artifact_path}.asc" |
| sig_valid = _verify_artifact_signature(artifact_path, signature_path) |
| |
| if sig_valid and checksum_valid: |
| print(f" ✓ All checks passed for {os.path.basename(artifact_path)}\n") |
| return True |
| return False |
| |
| |
| # ============================================================================ |
| # Step 1: Git Archive |
| # ============================================================================ |
| |
| |
| def _create_git_archive( |
| version: str, rc_num: str, output_dir: str = "dist", skip_signing: bool = False |
| ) -> str: |
| """Create git archive tar.gz for voting.""" |
| print(f"Creating git archive for version {version}-incubating...") |
| |
| os.makedirs(output_dir, exist_ok=True) |
| |
| archive_name = f"apache-burr-{version}-incubating-src.tar.gz" |
| archive_path = os.path.join(output_dir, archive_name) |
| prefix = f"apache-burr-{version}-incubating-src/" |
| |
| _run_command( |
| [ |
| "git", |
| "archive", |
| "HEAD", |
| f"--prefix={prefix}", |
| "--format=tar.gz", |
| "--output", |
| archive_path, |
| ], |
| description="", |
| error_message="Error creating git archive", |
| capture_output=False, |
| ) |
| print(f" ✓ Created git archive: {archive_path}") |
| |
| file_size = os.path.getsize(archive_path) |
| print(f" ✓ Archive size: {file_size:,} bytes") |
| |
| print("Signing archive..." if not skip_signing else "Creating checksum for archive...") |
| _sign_artifact(archive_path, skip_signing=skip_signing) |
| |
| # Verify |
| if not _verify_artifact_complete(archive_path, skip_signing=skip_signing): |
| _fail("Archive verification failed!") |
| |
| return archive_path |
| |
| |
| # ============================================================================ |
| # Step 2: Build Source Distribution (sdist) |
| # ============================================================================ |
| |
| |
| def _remove_ui_build_artifacts() -> None: |
| """Remove pre-built UI artifacts to ensure clean build.""" |
| ui_build_dir = os.path.join("burr", "tracking", "server", "build") |
| if os.path.exists(ui_build_dir): |
| print(f" Removing UI build artifacts: {ui_build_dir}") |
| shutil.rmtree(ui_build_dir) |
| print(" ✓ UI build artifacts removed") |
| |
| |
| def _build_sdist_from_git(version: str, output_dir: str = "dist") -> str: |
| """Build source distribution from git using flit.""" |
| _print_step(1, 2, "Building sdist with flit") |
| |
| os.makedirs(output_dir, exist_ok=True) |
| _remove_ui_build_artifacts() |
| _check_git_working_tree() |
| |
| env = os.environ.copy() |
| env["FLIT_USE_VCS"] = "0" |
| source_epoch = _source_date_epoch(version, output_dir) |
| if source_epoch is not None: |
| env["SOURCE_DATE_EPOCH"] = str(source_epoch) |
| _run_command( |
| ["flit", "build", "--format", "sdist"], |
| description="Running flit build --format sdist...", |
| error_message="Failed to build sdist", |
| success_message="flit sdist created successfully", |
| env=env, |
| ) |
| |
| # Find and rename sdist |
| expected_pattern = f"dist/apache_burr-{version.lower()}.tar.gz" |
| sdist_files = glob.glob(expected_pattern) |
| |
| if not sdist_files: |
| _fail(f"Could not find sdist: {expected_pattern}") |
| |
| original_sdist = sdist_files[0] |
| apache_sdist = os.path.join( |
| output_dir, f"apache-burr-{version.lower()}-incubating-sdist.tar.gz" |
| ) |
| |
| if os.path.exists(apache_sdist): |
| os.remove(apache_sdist) |
| |
| shutil.move(original_sdist, apache_sdist) |
| print(f" ✓ Renamed to: {os.path.basename(apache_sdist)}") |
| |
| return apache_sdist |
| |
| |
| def _source_date_epoch(version: str, output_dir: str = "dist") -> Optional[int]: |
| """Use the source archive timestamp when available so local rebuilds are comparable.""" |
| source_archive = os.path.join(output_dir, f"apache-burr-{version}-incubating-src.tar.gz") |
| if os.path.exists(source_archive): |
| return int(os.path.getmtime(source_archive)) |
| return None |
| |
| |
| # ============================================================================ |
| # Step 3: Build Wheel (SIMPLIFIED!) |
| # ============================================================================ |
| |
| |
| def _build_ui_artifacts() -> None: |
| """Build UI artifacts (npm build + copy to burr/tracking/server/build). |
| |
| This replicates the logic from burr.cli.__main__.run_build_ui_bash_commands() |
| without requiring burr to be installed. |
| """ |
| print("Building UI artifacts...") |
| |
| ui_source_dir = "telemetry/ui" |
| ui_build_dir = "burr/tracking/server/build" |
| |
| # Clean existing UI build |
| if os.path.exists(ui_build_dir): |
| shutil.rmtree(ui_build_dir) |
| |
| # Install npm dependencies |
| _run_command( |
| ["npm", "install", "--prefix", ui_source_dir], |
| description="Installing npm dependencies...", |
| error_message="npm install failed", |
| success_message="npm dependencies installed", |
| ) |
| |
| # Build UI with npm |
| _run_command( |
| ["npm", "run", "build", "--prefix", ui_source_dir], |
| description="Building UI with npm...", |
| error_message="npm build failed", |
| success_message="npm build completed", |
| ) |
| |
| # Copy build artifacts |
| print(" Copying build artifacts...") |
| os.makedirs(ui_build_dir, exist_ok=True) |
| ui_output = os.path.join(ui_source_dir, "build") |
| |
| try: |
| shutil.copytree(ui_output, ui_build_dir, dirs_exist_ok=True) |
| print(" ✓ Build artifacts copied") |
| except Exception as e: |
| _fail(f"Failed to copy build artifacts: {e}") |
| |
| # Verify |
| if not os.path.exists(ui_build_dir) or not os.listdir(ui_build_dir): |
| _fail(f"UI build directory is empty: {ui_build_dir}") |
| |
| |
| def _prepare_wheel_contents() -> tuple[bool, bool, Optional[str], list[tuple[str, str]]]: |
| """Prepare wheel contents and temporarily remove files excluded from the sdist.""" |
| burr_examples_dir = "burr/examples" |
| source_examples_dir = "examples" |
| removed_files: list[tuple[str, str]] = [] |
| |
| if not os.path.exists(source_examples_dir): |
| print(f" ⚠️ {source_examples_dir} not found") |
| return (False, False, None, removed_files) |
| |
| # Check if burr/examples is a symlink (should be in dev repo) |
| was_symlink = False |
| symlink_target = None |
| |
| # Use lexists (not exists) so we detect broken symlinks too — CI runners |
| # sometimes check out burr/examples as a symlink whose relative target |
| # doesn't resolve from the working directory, and os.path.exists would |
| # return False for such a link while os.makedirs would still blow up on it. |
| if os.path.lexists(burr_examples_dir): |
| if os.path.islink(burr_examples_dir): |
| was_symlink = True |
| symlink_target = os.readlink(burr_examples_dir) |
| print(f" Removing symlink: burr/examples -> {symlink_target}") |
| os.remove(burr_examples_dir) |
| else: |
| shutil.rmtree(burr_examples_dir) |
| |
| # Copy the 4 required examples |
| print(" Copying examples to burr/examples/...") |
| os.makedirs(burr_examples_dir, exist_ok=True) |
| |
| # Copy __init__.py |
| init_src = os.path.join(source_examples_dir, "__init__.py") |
| if os.path.exists(init_src): |
| shutil.copy2(init_src, os.path.join(burr_examples_dir, "__init__.py")) |
| |
| # Copy example directories |
| for example_dir in REQUIRED_EXAMPLES[1:]: # Skip __init__.py |
| src_path = os.path.join(source_examples_dir, example_dir) |
| dest_path = os.path.join(burr_examples_dir, example_dir) |
| |
| if os.path.exists(src_path) and os.path.isdir(src_path): |
| shutil.copytree(src_path, dest_path, dirs_exist_ok=True) |
| print(f" ✓ Copied {example_dir}") |
| |
| # Keep wheel contents aligned with the sdist so rebuild verification compares like-for-like artifacts. |
| excluded_wheel_files = [ |
| "burr/tracking/server/s3/deployment/terraform/.gitignore", |
| ] |
| for path in excluded_wheel_files: |
| if os.path.exists(path): |
| backup_dir = tempfile.mkdtemp(prefix="apache-release-wheel-") |
| backup_path = os.path.join(backup_dir, os.path.basename(path)) |
| os.replace(path, backup_path) |
| removed_files.append((path, backup_path)) |
| print(f" ✓ Temporarily excluded {path}") |
| |
| return (True, was_symlink, symlink_target, removed_files) |
| |
| |
| def _cleanup_wheel_contents( |
| was_symlink: bool, symlink_target: Optional[str], removed_files: list[tuple[str, str]] |
| ) -> None: |
| """Restore temporary wheel-build changes after the wheel build finishes.""" |
| burr_examples_dir = "burr/examples" |
| |
| if os.path.exists(burr_examples_dir): |
| shutil.rmtree(burr_examples_dir) |
| |
| if was_symlink and symlink_target: |
| print(f" Restoring symlink: burr/examples -> {symlink_target}") |
| os.symlink(symlink_target, burr_examples_dir) |
| print(" ✓ Symlink restored") |
| |
| for original_path, backup_path in removed_files: |
| if os.path.exists(backup_path): |
| os.replace(backup_path, original_path) |
| print(f" Restored {original_path}") |
| backup_dir = os.path.dirname(backup_path) |
| if os.path.isdir(backup_dir): |
| shutil.rmtree(backup_dir) |
| |
| |
| def _build_wheel_from_current_dir(version: str, output_dir: str = "dist") -> str: |
| """Build wheel from current directory (matches what voters do). |
| |
| This is MUCH simpler than the old approach: |
| - No temp directory extraction |
| - No copying UI between directories |
| - Just build in place and clean up |
| """ |
| _print_step(1, 3, "Building UI artifacts") |
| _build_ui_artifacts() |
| |
| _print_step(2, 3, "Preparing wheel contents") |
| copied, was_symlink, symlink_target, removed_files = _prepare_wheel_contents() |
| |
| _print_step(3, 3, "Building wheel with flit") |
| |
| try: |
| env = os.environ.copy() |
| env["FLIT_USE_VCS"] = "0" |
| source_epoch = _source_date_epoch(version, output_dir) |
| if source_epoch is not None: |
| env["SOURCE_DATE_EPOCH"] = str(source_epoch) |
| |
| _run_command( |
| ["flit", "build", "--format", "wheel"], |
| description="", |
| error_message="Wheel build failed", |
| success_message="Wheel built successfully", |
| env=env, |
| ) |
| |
| # Find the wheel |
| wheel_pattern = f"dist/apache_burr-{version}*.whl" |
| wheel_files = glob.glob(wheel_pattern) |
| |
| if not wheel_files: |
| _fail(f"No wheel found matching: {wheel_pattern}") |
| |
| wheel_path = wheel_files[0] |
| print(f" ✓ Wheel created: {os.path.basename(wheel_path)}") |
| |
| # flit build always writes to dist/. If the caller asked for a |
| # different output_dir (e.g. Release Validation's |
| # sdist-wheel-equivalence job builds from an extracted sdist and |
| # collects the wheel in /tmp/sdist-wheel), move it there. |
| if os.path.abspath(output_dir) != os.path.abspath("dist"): |
| os.makedirs(output_dir, exist_ok=True) |
| dest_path = os.path.join(output_dir, os.path.basename(wheel_path)) |
| shutil.move(wheel_path, dest_path) |
| wheel_path = dest_path |
| |
| return wheel_path |
| |
| finally: |
| # Always restore symlinks |
| if copied: |
| _cleanup_wheel_contents(was_symlink, symlink_target, removed_files) |
| |
| |
| def _verify_wheel(wheel_path: str) -> bool: |
| """Verify wheel contents are correct.""" |
| import zipfile |
| |
| print(f" Verifying wheel contents: {os.path.basename(wheel_path)}") |
| |
| try: |
| with zipfile.ZipFile(wheel_path, "r") as whl: |
| file_list = whl.namelist() |
| |
| # Check for UI build artifacts |
| ui_files = [f for f in file_list if "burr/tracking/server/build/" in f] |
| if not ui_files: |
| print(" ✗ No UI build artifacts found") |
| return False |
| print(f" ✓ Found {len(ui_files)} UI build files") |
| |
| # Check for required examples |
| for example in REQUIRED_EXAMPLES: |
| prefix = f"burr/examples/{example}" |
| example_files = [f for f in file_list if f.startswith(prefix)] |
| if not example_files: |
| print(f" ✗ Required example not found: {example}") |
| return False |
| |
| print(" ✓ All 4 required examples found") |
| print(f" ✓ Wheel contains {len(file_list)} total files") |
| return True |
| |
| except Exception as e: |
| print(f" ✗ Error verifying wheel: {e}") |
| return False |
| |
| |
| def _verify_wheel_with_twine(wheel_path: str) -> bool: |
| """Verify wheel metadata and package validity using twine.""" |
| print(f" Verifying wheel with twine: {os.path.basename(wheel_path)}") |
| |
| try: |
| subprocess.run( |
| ["twine", "check", wheel_path], |
| check=True, |
| capture_output=True, |
| text=True, |
| ) |
| print(" ✓ Twine check passed") |
| print(" ✓ Wheel metadata is valid") |
| return True |
| except subprocess.CalledProcessError as e: |
| print("\n❌ Twine metadata validation failed\n") |
| print("Twine output:") |
| if e.stdout: |
| print(e.stdout) |
| if e.stderr: |
| print(e.stderr) |
| _fail("Wheel failed twine validation - see output above for details") |
| |
| |
| # ============================================================================ |
| # Upload to Apache SVN |
| # ============================================================================ |
| |
| |
| def _collect_all_artifacts(version: str, output_dir: str = "dist") -> list[str]: |
| """Collect all artifacts for upload.""" |
| if not os.path.exists(output_dir): |
| return [] |
| |
| artifacts = [] |
| for filename in os.listdir(output_dir): |
| # Match both incubating artifacts and wheel (which doesn't have -incubating suffix) |
| version_match = f"{version}-incubating" in filename or f"{version}" in filename |
| if version_match: |
| if any(filename.endswith(ext) for ext in [".tar.gz", ".whl", ".asc", ".sha512"]): |
| artifacts.append(os.path.join(output_dir, filename)) |
| |
| return sorted(artifacts) |
| |
| |
| def _upload_to_svn( |
| version: str, |
| rc_num: str, |
| apache_id: str, |
| artifacts: list[str], |
| dry_run: bool = False, |
| ) -> Optional[str]: |
| """Upload artifacts to Apache SVN distribution repository.""" |
| svn_url = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}/{version}-incubating-RC{rc_num}" |
| |
| if dry_run: |
| print(f"\n[DRY RUN] Would upload to: {svn_url}") |
| return svn_url |
| |
| print(f"Uploading to: {svn_url}") |
| |
| try: |
| # Create directory |
| subprocess.run( |
| [ |
| "svn", |
| "mkdir", |
| "--parents", |
| "-m", |
| f"Creating directory for {version}-incubating-RC{rc_num}", |
| svn_url, |
| ], |
| check=True, |
| ) |
| |
| # Upload each file |
| for file_path in artifacts: |
| filename = os.path.basename(file_path) |
| print(f" Uploading {filename}...") |
| subprocess.run( |
| [ |
| "svn", |
| "import", |
| file_path, |
| f"{svn_url}/{filename}", |
| "-m", |
| f"Adding {filename}", |
| "--username", |
| apache_id, |
| ], |
| check=True, |
| ) |
| |
| print(f"\n✅ Artifacts uploaded to: {svn_url}") |
| return svn_url |
| |
| except subprocess.CalledProcessError as e: |
| print(f"Error during SVN upload: {e}") |
| return None |
| |
| |
| def _generate_vote_email(version: str, rc_num: str, svn_url: str) -> str: |
| """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) |
| |
| |
| 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) |
| |
| |
| 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: |
| """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 <rc_url> <release>/<version>`` 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 |
| # ============================================================================ |
| |
| |
| def cmd_archive(args) -> bool: |
| """Handle 'archive' subcommand.""" |
| _print_section(f"Creating Git Archive - v{args.version}-RC{args.rc_num}") |
| skip_signing = getattr(args, "skip_signing", False) |
| |
| _verify_project_root() |
| _validate_version(args.version) |
| _check_git_working_tree() |
| |
| archive_path = _create_git_archive( |
| args.version, args.rc_num, args.output_dir, skip_signing=skip_signing |
| ) |
| print(f"\n✅ Archive created: {archive_path}") |
| return True |
| |
| |
| def cmd_sdist(args) -> bool: |
| """Handle 'sdist' subcommand.""" |
| _print_section(f"Building Source Distribution - v{args.version}-RC{args.rc_num}") |
| skip_signing = getattr(args, "skip_signing", False) |
| |
| _verify_project_root() |
| _validate_version(args.version) |
| |
| sdist_path = _build_sdist_from_git(args.version, args.output_dir) |
| |
| _print_step(2, 2, "Signing sdist" if not skip_signing else "Checksumming sdist") |
| _sign_artifact(sdist_path, skip_signing=skip_signing) |
| |
| if not _verify_artifact_complete(sdist_path, skip_signing=skip_signing): |
| _fail("sdist verification failed!") |
| |
| print(f"\n✅ Source distribution created: {sdist_path}") |
| return True |
| |
| |
| def cmd_wheel(args) -> bool: |
| """Handle 'wheel' subcommand.""" |
| _print_section(f"Building Wheel - v{args.version}-RC{args.rc_num}") |
| skip_signing = getattr(args, "skip_signing", False) |
| |
| _verify_project_root() |
| _validate_version(args.version) |
| |
| wheel_path = _build_wheel_from_current_dir(args.version, args.output_dir) |
| |
| print("\nVerifying wheel with twine...") |
| if not _verify_wheel_with_twine(wheel_path): |
| _fail("Twine verification failed!") |
| |
| print("\nVerifying wheel contents...") |
| if not _verify_wheel(wheel_path): |
| _fail("Wheel verification failed!") |
| |
| print("\nSigning wheel..." if not skip_signing else "\nChecksumming wheel...") |
| _sign_artifact(wheel_path, skip_signing=skip_signing) |
| |
| if not _verify_artifact_complete(wheel_path, skip_signing=skip_signing): |
| _fail("Wheel signature/checksum verification failed!") |
| |
| print(f"\n✅ Wheel created and verified: {os.path.basename(wheel_path)}") |
| return True |
| |
| |
| def cmd_upload(args) -> bool: |
| """Handle 'upload' subcommand.""" |
| _print_section(f"Uploading Artifacts - v{args.version}-RC{args.rc_num}") |
| |
| artifacts = _collect_all_artifacts(args.version, args.artifacts_dir) |
| if not artifacts: |
| _fail(f"No artifacts found in {args.artifacts_dir}") |
| |
| print(f"Found {len(artifacts)} artifact(s):") |
| for artifact in artifacts: |
| print(f" - {os.path.basename(artifact)}") |
| |
| svn_url = _upload_to_svn( |
| args.version, args.rc_num, args.apache_id, artifacts, dry_run=args.dry_run |
| ) |
| |
| if not svn_url: |
| return False |
| |
| 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}") |
| |
| skip_signing = getattr(args, "skip_signing", False) |
| artifacts = _collect_all_artifacts(args.version, args.artifacts_dir) |
| |
| if not artifacts: |
| print(f"⚠️ No artifacts found in {args.artifacts_dir}") |
| return False |
| |
| all_valid = True |
| for artifact in artifacts: |
| if artifact.endswith((".asc", ".sha512")): |
| continue # Skip signature/checksum files |
| if not _verify_artifact_complete(artifact, skip_signing=skip_signing): |
| all_valid = False |
| |
| if all_valid: |
| print("✅ All artifacts verified successfully!") |
| else: |
| print("❌ Some artifacts failed verification") |
| |
| 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}") |
| |
| if args.dry_run: |
| print("*** DRY RUN MODE ***\n") |
| skip_signing = getattr(args, "skip_signing", False) |
| if skip_signing: |
| print("*** SKIP SIGNING MODE ***\n") |
| |
| _verify_project_root() |
| _validate_version(args.version) |
| _check_git_working_tree() |
| |
| # Step 1: Git Archive |
| _print_step(1, 4, "Creating git archive") |
| _create_git_archive(args.version, args.rc_num, args.output_dir, skip_signing=skip_signing) |
| |
| # Step 2: Build sdist |
| _print_step(2, 4, "Building sdist") |
| sdist_path = _build_sdist_from_git(args.version, args.output_dir) |
| _sign_artifact(sdist_path, skip_signing=skip_signing) |
| if not _verify_artifact_complete(sdist_path, skip_signing=skip_signing): |
| _fail("sdist verification failed!") |
| |
| # Step 3: Build wheel |
| _print_step(3, 4, "Building wheel") |
| wheel_path = _build_wheel_from_current_dir(args.version, args.output_dir) |
| if not _verify_wheel_with_twine(wheel_path): |
| _fail("Twine verification failed!") |
| _sign_artifact(wheel_path, skip_signing=skip_signing) |
| if not _verify_wheel(wheel_path) or not _verify_artifact_complete( |
| wheel_path, skip_signing=skip_signing |
| ): |
| _fail("Wheel verification failed!") |
| |
| # Step 4: Upload (if not disabled) |
| if not args.no_upload: |
| _print_step(4, 4, "Uploading to Apache SVN") |
| all_artifacts = _collect_all_artifacts(args.version, args.output_dir) |
| svn_url = _upload_to_svn( |
| args.version, args.rc_num, args.apache_id, all_artifacts, dry_run=args.dry_run |
| ) |
| if not svn_url: |
| _fail("SVN upload failed!") |
| else: |
| svn_url = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}/{args.version}-incubating-RC{args.rc_num}" |
| if args.dry_run: |
| print(f"\n[DRY RUN] Would upload to: {svn_url}") |
| |
| # Generate email template |
| _print_section("Release Complete!") |
| email_content = _generate_vote_email(args.version, args.rc_num, svn_url) |
| print(email_content) |
| |
| return True |
| |
| |
| # ============================================================================ |
| # CLI 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) |
| |
| # archive subcommand |
| archive_parser = subparsers.add_parser("archive", help="Create git archive") |
| archive_parser.add_argument("version", help="Version (e.g., '0.41.0')") |
| archive_parser.add_argument("rc_num", help="RC number (e.g., '0')") |
| archive_parser.add_argument("--output-dir", default="dist", help="Output directory") |
| archive_parser.add_argument( |
| "--skip-signing", |
| action="store_true", |
| help="Skip GPG signing (for CI). SHA512 checksum is still generated.", |
| ) |
| |
| # sdist subcommand |
| sdist_parser = subparsers.add_parser("sdist", help="Build source distribution") |
| sdist_parser.add_argument("version", help="Version") |
| sdist_parser.add_argument("rc_num", help="RC number") |
| sdist_parser.add_argument("--output-dir", default="dist") |
| sdist_parser.add_argument("--skip-signing", action="store_true") |
| |
| # wheel subcommand |
| wheel_parser = subparsers.add_parser("wheel", help="Build wheel") |
| wheel_parser.add_argument("version", help="Version") |
| wheel_parser.add_argument("rc_num", help="RC number") |
| wheel_parser.add_argument("--output-dir", default="dist") |
| wheel_parser.add_argument("--skip-signing", action="store_true") |
| |
| # upload subcommand |
| upload_parser = subparsers.add_parser("upload", help="Upload to SVN") |
| upload_parser.add_argument("version", help="Version") |
| upload_parser.add_argument("rc_num", help="RC number") |
| upload_parser.add_argument("apache_id", help="Apache ID") |
| 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") |
| verify_parser.add_argument( |
| "--skip-signing", |
| action="store_true", |
| help="Skip GPG signature verification (for builds produced with --skip-signing).", |
| ) |
| |
| # 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") |
| all_parser.add_argument("rc_num", help="RC number") |
| all_parser.add_argument("apache_id", help="Apache ID") |
| all_parser.add_argument("--output-dir", default="dist") |
| all_parser.add_argument("--dry-run", action="store_true") |
| all_parser.add_argument("--no-upload", action="store_true") |
| all_parser.add_argument( |
| "--skip-signing", |
| action="store_true", |
| 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 |
| _validate_environment_for_command(args) |
| |
| # Dispatch to command handler |
| try: |
| if args.command == "archive": |
| success = cmd_archive(args) |
| elif args.command == "sdist": |
| success = cmd_sdist(args) |
| elif args.command == "wheel": |
| 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: |
| _fail(f"Unknown command: {args.command}") |
| except KeyboardInterrupt: |
| print("\n\n⚠️ Interrupted by user") |
| sys.exit(130) |
| except Exception as e: |
| print(f"\n❌ Unexpected error: {e}") |
| import traceback |
| |
| traceback.print_exc() |
| sys.exit(1) |
| |
| if success: |
| print("\n✅ Command completed successfully!") |
| sys.exit(0) |
| else: |
| print("\n❌ Command failed.") |
| sys.exit(1) |
| |
| |
| if __name__ == "__main__": |
| main() |