| #!/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" | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| 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 _release_build_environment(source_date_epoch: int) -> dict[str, str]: | |
| """Return the shared environment for reproducible Flit builds.""" | |
| env = os.environ.copy() | |
| env["FLIT_USE_VCS"] = "0" | |
| env["SOURCE_DATE_EPOCH"] = str(source_date_epoch) | |
| return env | |
| def _git_source_date_epoch() -> int: | |
| """Return the anchored HEAD commit timestamp for official release builds.""" | |
| result = _run_command( | |
| [ | |
| "git", | |
| "-C", | |
| str(PROJECT_ROOT), | |
| "-c", | |
| "log.showSignature=false", | |
| "show", | |
| "-s", | |
| "--format=%ct", | |
| "HEAD", | |
| ], | |
| description="Reading the source commit timestamp...", | |
| error_message="Could not determine the HEAD commit timestamp", | |
| ) | |
| raw_epoch = result.stdout.strip() | |
| if not raw_epoch.isdigit(): | |
| _fail(f"Git returned an invalid commit timestamp: {raw_epoch!r}") | |
| ambient_epoch = os.environ.get("SOURCE_DATE_EPOCH", "").strip() | |
| if ambient_epoch and ambient_epoch != raw_epoch: | |
| print( | |
| " ⚠️ Ignoring ambient SOURCE_DATE_EPOCH=" | |
| f"{ambient_epoch}; using HEAD commit timestamp {raw_epoch}" | |
| ) | |
| return int(raw_epoch) | |
| def _environment_source_date_epoch() -> Optional[int]: | |
| """Return an explicitly configured rebuild epoch, treating blank as unset.""" | |
| raw_epoch = os.environ.get("SOURCE_DATE_EPOCH", "").strip() | |
| if not raw_epoch: | |
| return None | |
| try: | |
| return int(raw_epoch) | |
| except ValueError: | |
| _fail("SOURCE_DATE_EPOCH must be an integer Unix timestamp") | |
| def _wheel_source_date_epoch() -> int: | |
| """Use an explicit rebuild epoch, or the anchored commit timestamp in Git.""" | |
| configured_epoch = _environment_source_date_epoch() | |
| return configured_epoch if configured_epoch is not None else _git_source_date_epoch() | |
| def _build_sdist_from_git( | |
| version: str, output_dir: str = "dist", source_date_epoch: Optional[int] = None | |
| ) -> str: | |
| """Build the sdist from a staged copy of the committed source tree. | |
| The complete source release and the Python sdist contain different files, | |
| so each needs a LICENSE describing its own contents. Keep the repository's | |
| LICENSE intact for the source release and install LICENSE-sdist as LICENSE | |
| only in the isolated tree used to build the sdist. | |
| """ | |
| _print_step(1, 2, "Building sdist with flit") | |
| output_dir = os.path.abspath(output_dir) | |
| os.makedirs(output_dir, exist_ok=True) | |
| _check_git_working_tree() | |
| epoch = source_date_epoch if source_date_epoch is not None else _git_source_date_epoch() | |
| with tempfile.TemporaryDirectory(prefix="apache-burr-sdist-") as temp_dir: | |
| temp_path = Path(temp_dir) | |
| source_tree = temp_path / "source" | |
| source_tree.mkdir() | |
| source_archive = temp_path / "source.tar" | |
| _run_command( | |
| ["git", "archive", "HEAD", "--format=tar", "--output", str(source_archive)], | |
| description="Staging committed source files...", | |
| error_message="Failed to stage source files for the sdist", | |
| ) | |
| unpack_options: dict[str, Any] = {} | |
| if sys.version_info >= (3, 12): | |
| # This archive was just produced from our own Git repository. Be | |
| # explicit on newer Python versions while retaining Python 3.9-3.11 | |
| # compatibility, where shutil has no filter argument. | |
| unpack_options["filter"] = "fully_trusted" | |
| shutil.unpack_archive(str(source_archive), str(source_tree), format="tar", **unpack_options) | |
| sdist_license = source_tree / "LICENSE-sdist" | |
| if not sdist_license.is_file(): | |
| _fail("LICENSE-sdist is missing from the committed source tree") | |
| shutil.copyfile(sdist_license, source_tree / "LICENSE") | |
| sdist_license.unlink() | |
| # A clean git archive normally has no ignored UI build output, but | |
| # remove this defensively in case it becomes tracked in the future. | |
| shutil.rmtree(source_tree / "burr" / "tracking" / "server" / "build", ignore_errors=True) | |
| _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=_release_build_environment(epoch), | |
| cwd=source_tree, | |
| ) | |
| original_sdist = source_tree / "dist" / f"apache_burr-{version.lower()}.tar.gz" | |
| if not original_sdist.is_file(): | |
| _fail(f"Could not find sdist: {original_sdist}") | |
| 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 | |
| # ============================================================================ | |
| # 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", source_date_epoch: Optional[int] = None | |
| ) -> 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: | |
| epoch = source_date_epoch if source_date_epoch is not None else _wheel_source_date_epoch() | |
| _run_command( | |
| ["flit", "build", "--format", "wheel"], | |
| description="", | |
| error_message="Wheel build failed", | |
| success_message="Wheel built successfully", | |
| env=_release_build_environment(epoch), | |
| ) | |
| # 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() | |
| source_date_epoch = _git_source_date_epoch() | |
| # 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, source_date_epoch=source_date_epoch | |
| ) | |
| _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, source_date_epoch=source_date_epoch | |
| ) | |
| 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() |