blob: 1ec8c096a73dcacc8d958c3afed6e652f4435932 [file]
#!/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 _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 _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 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()
epoch = source_date_epoch if source_date_epoch is not None else _git_source_date_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=_release_build_environment(epoch),
)
# 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
# ============================================================================
# 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()