| # 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. | |
| # /// script | |
| # dependencies = [ | |
| # "build", | |
| # "flit", | |
| # "twine", | |
| # ] | |
| # /// | |
| import argparse | |
| import glob | |
| import hashlib | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import tempfile | |
| import zipfile | |
| # --- Configuration --- | |
| PROJECT_SHORT_NAME = "hamilton" | |
| # Package configurations: each Hamilton package has its own settings | |
| PACKAGE_CONFIGS = { | |
| "hamilton": { | |
| "name": "apache-hamilton", | |
| "working_dir": ".", | |
| "version_file": "hamilton/version.py", | |
| "version_pattern": r"VERSION = \((\d+), (\d+), (\d+)(, \"(\w+)\")?\)", | |
| "version_extractor": lambda match: f"{match.group(1)}.{match.group(2)}.{match.group(3)}", | |
| }, | |
| "sdk": { | |
| "name": "apache-hamilton-sdk", | |
| "working_dir": "ui/sdk", | |
| "version_file": "ui/sdk/pyproject.toml", | |
| "version_pattern": r'version\s*=\s*"(\d+\.\d+\.\d+)"', | |
| "version_extractor": lambda match: match.group(1), | |
| }, | |
| "lsp": { | |
| "name": "apache-hamilton-lsp", | |
| "working_dir": "dev_tools/language_server", | |
| "version_file": "dev_tools/language_server/pyproject.toml", | |
| "version_pattern": r'version\s*=\s*"(\d+\.\d+\.\d+)"', | |
| "version_extractor": lambda match: match.group(1), | |
| }, | |
| "contrib": { | |
| "name": "apache-hamilton-contrib", | |
| "working_dir": "contrib", | |
| "version_file": "contrib/pyproject.toml", | |
| "version_pattern": r'version\s*=\s*"(\d+\.\d+\.\d+)"', | |
| "version_extractor": lambda match: match.group(1), | |
| }, | |
| "ui": { | |
| "name": "apache-hamilton-ui", | |
| "working_dir": "ui/backend", | |
| "version_file": "ui/backend/pyproject.toml", | |
| "version_pattern": r'version\s*=\s*"(\d+\.\d+\.\d+)"', | |
| "version_extractor": lambda match: match.group(1), | |
| }, | |
| } | |
| # Legacy configuration (kept for backward compatibility with single VERSION_FILE references) | |
| VERSION_FILE = "hamilton/version.py" | |
| VERSION_PATTERN = r"VERSION = \((\d+), (\d+), (\d+)(, \"(\w+)\")?\)" | |
| def get_version_from_file(package_config: dict) -> str: | |
| """Get the version from a file using package-specific configuration.""" | |
| import re | |
| file_path = package_config["version_file"] | |
| pattern = package_config["version_pattern"] | |
| extractor = package_config["version_extractor"] | |
| with open(file_path) as f: | |
| content = f.read() | |
| match = re.search(pattern, content) | |
| if match: | |
| # Check for RC in the match (only for main hamilton package) | |
| if len(match.groups()) >= 5 and match.group(5): | |
| raise ValueError("Do not commit RC to the version file.") | |
| version = extractor(match) | |
| return version | |
| raise ValueError(f"Could not find version in {file_path}") | |
| def check_prerequisites(): | |
| """Checks for necessary command-line tools and Python modules.""" | |
| print("Checking for required tools...") | |
| required_tools = ["git", "gpg", "svn", "twine"] | |
| for tool in required_tools: | |
| if shutil.which(tool) is None: | |
| print(f"Error: '{tool}' not found. Please install it and ensure it's in your PATH.") | |
| sys.exit(1) | |
| try: | |
| import build # noqa:F401 | |
| print("Python 'build' module found.") | |
| except ImportError: | |
| print( | |
| "Error: The 'build' module is not installed. Please install it with 'pip install build'." | |
| ) | |
| sys.exit(1) | |
| print("All required tools found.") | |
| def update_version(package_config: dict, version, rc_num): | |
| """Updates the version number in the specified file.""" | |
| import re | |
| version_file = package_config["version_file"] | |
| pattern = package_config["version_pattern"] | |
| print(f"Updating version in {version_file} to {version} RC{rc_num}...") | |
| try: | |
| with open(version_file, "r") as f: | |
| content = f.read() | |
| # Only the main hamilton package uses the tuple format with RC | |
| if package_config["name"] == "apache-hamilton": | |
| major, minor, patch = version.split(".") | |
| if int(rc_num) >= 0: | |
| new_version_tuple = f'VERSION = ({major}, {minor}, {patch}, "RC{rc_num}")' | |
| else: | |
| new_version_tuple = f"VERSION = ({major}, {minor}, {patch})" | |
| new_content = re.sub(pattern, new_version_tuple, content) | |
| else: | |
| # Other packages use pyproject.toml with simple version string | |
| # For now, we don't update these with RC numbers in pyproject.toml | |
| print( | |
| f"Note: Version updates for {package_config['name']} are manual in pyproject.toml" | |
| ) | |
| return True | |
| if new_content == content: | |
| print("Error: Could not find or replace version string. Check your VERSION_PATTERN.") | |
| return False | |
| with open(version_file, "w") as f: | |
| f.write(new_content) | |
| print("Version updated successfully.") | |
| return True | |
| except FileNotFoundError: | |
| print(f"Error: {version_file} not found.") | |
| return False | |
| except Exception as e: | |
| print(f"An error occurred while updating the version: {e}") | |
| return False | |
| def verify_wheel_with_twine(wheel_path: str) -> bool: | |
| """Validates wheel metadata using twine check.""" | |
| print(f"Validating wheel with twine: {wheel_path}") | |
| try: | |
| result = subprocess.run( | |
| ["twine", "check", wheel_path], | |
| capture_output=True, | |
| text=True, | |
| ) | |
| print(result.stdout) | |
| if result.returncode != 0: | |
| print(f"twine check failed:\n{result.stderr}") | |
| return False | |
| print("Wheel passed twine validation.") | |
| return True | |
| except Exception as e: | |
| print(f"Error running twine check: {e}") | |
| return False | |
| def sign_artifacts(archive_name: str) -> list[str] | None: | |
| """Creates signed files for the designated artifact.""" | |
| files = [] | |
| # Sign the tarball with GPG. The user must have a key configured. | |
| try: | |
| subprocess.run( | |
| ["gpg", "--armor", "--output", f"{archive_name}.asc", "--detach-sig", archive_name], | |
| check=True, | |
| ) | |
| files.append(f"{archive_name}.asc") | |
| print(f"Created GPG signature: {archive_name}.asc") | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error signing tarball: {e}") | |
| print( | |
| "Tip: if you don't have a GPG key configured, re-run with --no-sign (and optionally\n" | |
| "--no-upload) to skip signing and SVN upload (useful for local testing).\n" | |
| "To set up a GPG key for real releases:\n" | |
| " 1. gpg --gen-key\n" | |
| " 2. gpg --list-secret-keys # note your KEYID\n" | |
| " 3. gpg --armor --export <KEYID> >> KEYS\n" | |
| " 4. Set default-key <KEYID> in ~/.gnupg/gpg.conf if you have multiple keys.\n" | |
| " 5. Upload your key fingerprint to id.apache.org before voting." | |
| ) | |
| return None | |
| # Generate SHA512 checksum. | |
| sha512_hash = hashlib.sha512() | |
| with open(archive_name, "rb") as f: | |
| while True: | |
| data = f.read(65536) | |
| if not data: | |
| break | |
| sha512_hash.update(data) | |
| with open(f"{archive_name}.sha512", "w") as f: | |
| f.write(f"{sha512_hash.hexdigest()}\n") | |
| print(f"Created SHA512 checksum: {archive_name}.sha512") | |
| files.append(f"{archive_name}.sha512") | |
| return files | |
| def _modify_wheel_for_apache_release(original_wheel: str, new_wheel_path: str, package_name: str): | |
| """Helper to modify the wheel for apache release. | |
| # Flit somehow builds something incorrectly. | |
| # 1. change PKG-INFO's first line to be `Metadata-Version: 2.4` | |
| # 2. make sure the second line is `Name: {package_name}` | |
| # 3. remove the `Import-Name:` line from PKG-INFO. | |
| :param original_wheel: Path to the original wheel. | |
| :param new_wheel_path: Path to the new wheel to create. | |
| :param package_name: The Apache package name (e.g., 'apache-hamilton') | |
| """ | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| # Unzip the wheel | |
| with zipfile.ZipFile(original_wheel, "r") as zip_ref: | |
| zip_ref.extractall(tmpdir) | |
| # Find the .dist-info directory | |
| dist_info_dirs = glob.glob(os.path.join(tmpdir, "*.dist-info")) | |
| if not dist_info_dirs: | |
| raise ValueError(f"Could not find .dist-info directory in {original_wheel}") | |
| dist_info_dir = dist_info_dirs[0] | |
| pkg_info = os.path.join(dist_info_dir, "PKG-INFO") | |
| _modify_pkg_info_file(pkg_info, package_name) | |
| # Create the new wheel | |
| with zipfile.ZipFile(new_wheel_path, "w", zipfile.ZIP_DEFLATED) as zip_ref: | |
| for root, _, files in os.walk(tmpdir): | |
| for file in files: | |
| zip_ref.write( | |
| os.path.join(root, file), os.path.relpath(os.path.join(root, file), tmpdir) | |
| ) | |
| def _modify_pkg_info_file(pkg_info_path: str, package_name: str): | |
| """ | |
| Flit somehow builds something incorrectly. | |
| 1. change PKG-INFO's first line to be `Metadata-Version: 2.4` | |
| 2. make sure the second line is `Name: {package_name}` | |
| 3. remove the `Import-Name:` line from PKG-INFO if present. | |
| """ | |
| with open(pkg_info_path, "r") as f: | |
| lines = f.readlines() | |
| new_lines = [] | |
| for i, line in enumerate(lines): | |
| if i == 0: | |
| new_lines.append("Metadata-Version: 2.4\n") | |
| elif i == 1: | |
| new_lines.append(f"Name: {package_name}\n") | |
| elif line.startswith("Import-Name:"): | |
| continue # Skip this line | |
| else: | |
| new_lines.append(line) | |
| with open(pkg_info_path, "w") as f: | |
| f.writelines(new_lines) | |
| def _modify_tarball_for_apache_release( | |
| original_tarball: str, new_tarball_path: str, package_name: str | |
| ): | |
| """Helper to modify the tarball for apache release. | |
| # Flit somehow builds something incorrectly. | |
| # 1. change PKG-INFO's first line to be `Metadata-Version: 2.4` | |
| # 2. make sure the second line is `Name: {package_name}` | |
| # 3. remove the `Import-Name:` line from PKG-INFO. | |
| :param original_tarball: Path to the original tarball. | |
| :param new_tarball_path: Path to the new tarball to create. | |
| :param package_name: The Apache package name (e.g., 'apache-hamilton') | |
| """ | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| # Extract the tarball | |
| with tarfile.open(original_tarball, "r:gz") as tar: | |
| tar.extractall(path=tmpdir) | |
| # Modify the PKG-INFO file | |
| # The extracted tarball has a single directory inside. | |
| extracted_dir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) | |
| pkg_info_path = os.path.join(extracted_dir, "PKG-INFO") | |
| _modify_pkg_info_file(pkg_info_path, package_name) | |
| # Create the new tarball | |
| with tarfile.open(new_tarball_path, "w:gz") as tar: | |
| tar.add(extracted_dir, arcname=os.path.basename(extracted_dir)) | |
| def create_release_artifacts(package_config: dict, version, no_sign: bool = False) -> list[str]: | |
| """Creates the source tarball, GPG signature, and checksums using flit build.""" | |
| package_name = package_config["name"] | |
| working_dir = package_config["working_dir"] | |
| print(f"Creating release artifacts for {package_name} with 'flit build'...") | |
| # Save current directory and change to package working directory | |
| original_dir = os.getcwd() | |
| if working_dir != ".": | |
| os.chdir(working_dir) | |
| try: | |
| # Clean the dist directory before building. | |
| if os.path.exists("dist"): | |
| shutil.rmtree("dist") | |
| # For the UI package, build sdist first (without frontend assets), | |
| # then build frontend, then build wheel (with frontend assets). | |
| # This keeps compiled JS/CSS out of the source tarball (avoiding | |
| # third-party license obligations) while including them in the wheel. | |
| if package_name == "apache-hamilton-ui": | |
| # Step 1: Build sdist without frontend assets | |
| try: | |
| subprocess.run( | |
| ["flit", "build", "--no-use-vcs", "--format", "sdist"], | |
| check=True, | |
| ) | |
| print("Source distribution created successfully (without frontend).") | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error creating source distribution: {e}") | |
| return None | |
| # Step 2: Build frontend and copy to hamilton_ui/build/ | |
| print("Building UI frontend (npm install + npm run build)...") | |
| frontend_dir = os.path.join(original_dir, "ui", "frontend") | |
| build_target = os.path.join("hamilton_ui", "build") | |
| try: | |
| subprocess.run( | |
| ["npm", "install", "--prefix", frontend_dir], | |
| check=True, | |
| ) | |
| subprocess.run( | |
| ["npm", "run", "build", "--prefix", frontend_dir], | |
| check=True, | |
| ) | |
| # Generate third-party license attributions for the bundled | |
| # frontend dependencies (required since the wheel ships | |
| # compiled JS/CSS). Writes build/THIRD-PARTY-LICENSES.txt. | |
| subprocess.run( | |
| ["npm", "run", "licenses", "--prefix", frontend_dir], | |
| check=True, | |
| ) | |
| # Copy built assets to hamilton_ui/build/ | |
| if os.path.exists(build_target): | |
| shutil.rmtree(build_target) | |
| # Vite outputs to frontend/dist/, CRA outputs to frontend/build/ | |
| frontend_build = os.path.join(frontend_dir, "dist") | |
| if not os.path.exists(frontend_build): | |
| frontend_build = os.path.join(frontend_dir, "build") | |
| shutil.copytree(frontend_build, build_target) | |
| print(f"Frontend built and copied to {build_target}") | |
| except (subprocess.CalledProcessError, FileNotFoundError) as e: | |
| print(f"Error building frontend: {e}") | |
| print("Ensure Node.js and npm are installed.") | |
| return None | |
| # Step 3: Build wheel (includes frontend assets via package data) | |
| try: | |
| subprocess.run( | |
| ["flit", "build", "--no-use-vcs", "--format", "wheel"], | |
| check=True, | |
| ) | |
| print("Wheel created successfully (with frontend assets).") | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error creating wheel: {e}") | |
| return None | |
| else: | |
| # Non-UI packages: build both sdist and wheel in one step | |
| try: | |
| subprocess.run( | |
| ["flit", "build", "--no-use-vcs"], | |
| check=True, | |
| ) | |
| print("Source distribution created successfully.") | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error creating source distribution: {e}") | |
| return None | |
| # Find the created tarball in the dist directory. | |
| # Convert package name with underscores for file naming | |
| package_file_name = package_name.replace("-", "_") | |
| expected_tar_ball = f"dist/{package_file_name}-{version.lower()}.tar.gz" | |
| tarball_path = glob.glob(expected_tar_ball) | |
| if len(tarball_path) != 1: | |
| print( | |
| f"Error: Expected exactly 1 tarball matching {expected_tar_ball}, " | |
| f"found {len(tarball_path)}." | |
| ) | |
| if os.path.exists("dist"): | |
| print("Contents of 'dist' directory:") | |
| for item in os.listdir("dist"): | |
| print(f"- {item}") | |
| raise ValueError(f"Could not find the generated source tarball: {expected_tar_ball}") | |
| tarball_file = tarball_path[0] | |
| # Copy the tarball to be {package-name}-{version}-incubating-src.tar.gz | |
| # Use -src suffix to distinguish source distribution from wheel (convenience package) | |
| new_tar_ball = f"dist/{package_name}-{version.lower()}-incubating-src.tar.gz" | |
| _modify_tarball_for_apache_release(tarball_file, new_tar_ball, package_name) | |
| # Remove original flit tarball (only keep the incubating copy) | |
| os.remove(tarball_file) | |
| archive_name = new_tar_ball | |
| print(f"Found source tarball: {archive_name}") | |
| if no_sign: | |
| print("Skipping GPG signing (--no-sign).") | |
| new_tar_ball_signed = [] | |
| else: | |
| new_tar_ball_signed = sign_artifacts(archive_name) | |
| if new_tar_ball_signed is None: | |
| raise ValueError("Could not sign the main release artifacts.") | |
| # Wheel keeps its original PEP 427 filename (no -incubating suffix) | |
| expected_wheel = f"dist/{package_file_name}-{version.lower()}-py3-none-any.whl" | |
| wheel_path = glob.glob(expected_wheel) | |
| if len(wheel_path) != 1: | |
| raise ValueError( | |
| f"Expected exactly 1 wheel matching {expected_wheel}, found {len(wheel_path)}." | |
| ) | |
| wheel_file = wheel_path[0] | |
| # Verify wheel with twine before signing | |
| if not verify_wheel_with_twine(wheel_file): | |
| raise ValueError("Wheel failed twine validation.") | |
| if no_sign: | |
| wheel_signed_files = [] | |
| else: | |
| wheel_signed_files = sign_artifacts(wheel_file) | |
| files_to_upload = [new_tar_ball, *new_tar_ball_signed, wheel_file, *wheel_signed_files] | |
| # Return absolute paths: the caller (svn_upload) runs from the repo root, | |
| # but these paths are relative to the package working_dir we chdir'd into. | |
| return [os.path.abspath(f) for f in files_to_upload] | |
| finally: | |
| # Always return to original directory | |
| os.chdir(original_dir) | |
| def svn_upload(package_name: str, version, rc_num, files_to_import: list[str], apache_id): | |
| """Uploads the artifacts to the ASF dev distribution repository. | |
| files_to_import: Get the files to import (tarball, asc, sha512). | |
| """ | |
| print("Uploading artifacts to ASF SVN...") | |
| # Include package name in SVN path for multi-package support | |
| svn_path = f"https://dist.apache.org/repos/dist/dev/incubator/{PROJECT_SHORT_NAME}/{package_name}/{version}-RC{rc_num}" | |
| try: | |
| # Create a new directory for the release candidate. | |
| # Tolerate an already-existing directory (e.g. re-running after a | |
| # partial upload): mkdir failing because the path exists is not fatal, | |
| # we still want to import the files below. | |
| print( | |
| f"Creating directory for {package_name} {version}-incubating-RC{rc_num}... at {svn_path}" | |
| ) | |
| mkdir_result = subprocess.run( | |
| [ | |
| "svn", | |
| "mkdir", | |
| "--parents", | |
| "-m", | |
| f"Creating directory for {package_name} {version}-incubating-RC{rc_num}", | |
| svn_path, | |
| ], | |
| capture_output=True, | |
| text=True, | |
| ) | |
| if mkdir_result.returncode != 0: | |
| if "already exists" in (mkdir_result.stderr or ""): | |
| print(f"Directory already exists at {svn_path}, continuing to import files.") | |
| else: | |
| print(f"Error creating SVN directory: {mkdir_result.stderr.strip()}") | |
| return None | |
| # Use svn import for the new directory. | |
| for file_path in files_to_import: | |
| subprocess.run( | |
| [ | |
| "svn", | |
| "import", | |
| file_path, | |
| f"{svn_path}/{os.path.basename(file_path)}", | |
| "-m", | |
| f"Adding {os.path.basename(file_path)}", | |
| "--username", | |
| apache_id, | |
| ], | |
| check=True, | |
| ) | |
| print(f"Imported {file_path} to {svn_path}") | |
| print(f"Artifacts successfully uploaded to: {svn_path}") | |
| return svn_path | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error during SVN upload: {e}") | |
| print("Make sure you have svn access configured for your Apache ID.") | |
| return None | |
| def generate_email_template(package_name: str, version, rc_num, svn_url): | |
| """Generates the content for the [VOTE] email.""" | |
| print("Generating email template...") | |
| version_with_incubating = f"{version}-incubating" | |
| tag = f"{package_name}-v{version}" | |
| email_content = f"""[VOTE] Release Apache {PROJECT_SHORT_NAME} - {package_name} {version_with_incubating} (release candidate {rc_num}) | |
| Hi all, | |
| This is a call for a vote on releasing Apache {PROJECT_SHORT_NAME} {package_name} {version_with_incubating}, | |
| release candidate {rc_num}. | |
| This release includes the following changes (see CHANGELOG for details): | |
| - [List key changes here] | |
| The artifacts for this release candidate can be found at: | |
| {svn_url} | |
| The Git tag to be voted upon is: | |
| {tag} | |
| The release hash is: | |
| [Insert git commit hash here] | |
| Release artifacts are signed with the following key: | |
| [Insert your GPG key ID here] | |
| The KEYS file is available at: | |
| https://downloads.apache.org/incubator/{PROJECT_SHORT_NAME}/KEYS | |
| Please download, verify, and test the release candidate. | |
| For testing, please run some of the examples, scripts/qualify.sh has | |
| a sampling of them to run. | |
| The vote will run for a minimum of 72 hours. | |
| Please vote: | |
| [ ] +1 Release this package as Apache {PROJECT_SHORT_NAME} {package_name} {version_with_incubating} | |
| [ ] +0 No opinion | |
| [ ] -1 Do not release this package because... (Please provide a reason) | |
| Checklist for reference: | |
| [ ] Incubating in name. | |
| [ ] Download links are valid. | |
| [ ] Checksums and signatures. | |
| [ ] LICENSE/NOTICE/DISCLAIMER files exist | |
| [ ] No unexpected binary files | |
| [ ] All source files have ASF headers | |
| [ ] Can compile from source | |
| On behalf of the Apache {PROJECT_SHORT_NAME} PPMC, | |
| [Your Name] | |
| """ | |
| print("\n" + "=" * 80) | |
| print("EMAIL TEMPLATE (COPY AND PASTE TO YOUR MAILING LIST)") | |
| print("=" * 80) | |
| print(email_content) | |
| print("=" * 80) | |
| def main(): | |
| """ | |
| ### How to Use the Updated Script | |
| 1. **Install the `flit` module**: | |
| ```bash | |
| pip install flit | |
| ``` | |
| 2. **Configure the Script**: The script now supports multiple Hamilton packages. | |
| Available packages: hamilton, sdk, lsp, contrib, ui | |
| 3. **Prerequisites**: | |
| * You must have `git`, `gpg`, `svn`, and the `flit` Python module installed. | |
| * Your GPG key and SVN access must be configured for your Apache ID. | |
| 4. **Run the Script**: | |
| Open your terminal, navigate to the root of your project directory, and run the script | |
| with the desired package, version, release candidate number, and Apache ID. | |
| Note: if you have multiple gpg keys, specify the default in ~/.gnupg/gpg.conf add a line with `default-key <KEYID>`. | |
| Use --no-sign (-ns) to skip GPG signing (e.g., when a key is not available locally). | |
| This implicitly sets --no-upload, since unsigned artifacts must never be published. | |
| Use --no-upload (-nu) alone to build and sign without publishing (e.g., to rehearse signing). | |
| Examples: | |
| python apache_release_helper.py --package hamilton 1.89.0 0 your_apache_id | |
| python apache_release_helper.py --package sdk 0.8.0 0 your_apache_id | |
| python apache_release_helper.py --package lsp 0.1.0 0 your_apache_id | |
| python apache_release_helper.py --package contrib 0.0.8 0 your_apache_id | |
| python apache_release_helper.py --package ui 0.0.17 0 your_apache_id | |
| python apache_release_helper.py -ns --package ui 0.0.18 0 | |
| """ | |
| parser = argparse.ArgumentParser( | |
| description="Automates parts of the Apache release process for Hamilton packages." | |
| ) | |
| parser.add_argument( | |
| "--package", | |
| required=True, | |
| choices=list(PACKAGE_CONFIGS.keys()), | |
| help="Which Hamilton package to release (hamilton, sdk, lsp, contrib, ui)", | |
| ) | |
| parser.add_argument("version", help="The new release version (e.g., '1.0.0').") | |
| parser.add_argument("rc_num", help="The release candidate number (e.g., '0' for RC0).") | |
| parser.add_argument( | |
| "apache_id", | |
| nargs="?", | |
| default=None, | |
| help="Your Apache user ID. Required unless --no-upload is set.", | |
| ) | |
| parser.add_argument( | |
| "-ns", | |
| "--no-sign", | |
| action="store_true", | |
| help="Skip GPG signing. Unsigned artifacts cannot be used for an official release vote.", | |
| ) | |
| parser.add_argument( | |
| "-nu", | |
| "--no-upload", | |
| action="store_true", | |
| help="Skip git tagging and SVN upload. Useful for validating the build locally.", | |
| ) | |
| args = parser.parse_args() | |
| if args.no_sign: | |
| args.no_upload = True # never upload unsigned artifacts | |
| package_key = args.package | |
| version = args.version | |
| rc_num = args.rc_num | |
| apache_id = args.apache_id | |
| if not args.no_upload and not apache_id: | |
| parser.error("apache_id is required unless --no-upload (or --no-sign) is set.") | |
| # Get package configuration | |
| package_config = PACKAGE_CONFIGS[package_key] | |
| package_name = package_config["name"] | |
| print(f"\n{'=' * 80}") | |
| print(f" Apache Hamilton Release Helper - {package_name}") | |
| print(f"{'=' * 80}\n") | |
| check_prerequisites() | |
| # Validate version matches what's in the version file | |
| current_version = get_version_from_file(package_config) | |
| print(f"Current version in {package_config['version_file']}: {current_version}") | |
| if current_version != version: | |
| print("Update the version in the version file to match the expected version.") | |
| sys.exit(1) | |
| # Create git tag (from repo root) | |
| tag_name = f"{package_name}-v{version}-incubating-RC{rc_num}" | |
| if args.no_upload: | |
| print(f"\n[--no-upload] Skipping git tag creation: {tag_name}") | |
| else: | |
| print(f"\nChecking for git tag '{tag_name}'...") | |
| try: | |
| # Check if the tag already exists | |
| existing_tag = subprocess.check_output(["git", "tag", "-l", tag_name]).decode().strip() | |
| if existing_tag == tag_name: | |
| print(f"Git tag '{tag_name}' already exists.") | |
| response = input( | |
| "Do you want to continue without creating a new tag? (y/n): " | |
| ).lower() | |
| if response != "y": | |
| print("Aborting.") | |
| sys.exit(1) | |
| else: | |
| # Tag does not exist, create it | |
| print(f"Creating git tag '{tag_name}'...") | |
| subprocess.run(["git", "tag", tag_name], check=True) | |
| print(f"Git tag {tag_name} created.") | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error checking or creating Git tag: {e}") | |
| sys.exit(1) | |
| # Create artifacts | |
| print(f"\n{'=' * 80}") | |
| print(" Building Release Artifacts") | |
| print(f"{'=' * 80}\n") | |
| files_to_upload = create_release_artifacts(package_config, version, no_sign=args.no_sign) | |
| if not files_to_upload: | |
| sys.exit(1) | |
| if args.no_upload: | |
| print(f"\n{'=' * 80}") | |
| print(" [--no-upload] Skipping SVN upload") | |
| print(f"{'=' * 80}\n") | |
| print("Artifacts built successfully:") | |
| for f in files_to_upload: | |
| print(f" {f}") | |
| if args.no_sign: | |
| print("\nNote: artifacts are unsigned and cannot be used for an official release vote.") | |
| else: | |
| # Upload artifacts | |
| print(f"\n{'=' * 80}") | |
| print(" Uploading to Apache SVN") | |
| print(f"{'=' * 80}\n") | |
| # NOTE: You MUST have your SVN client configured to use your Apache ID and have permissions. | |
| svn_url = svn_upload(package_name, version, rc_num, files_to_upload, apache_id) | |
| if not svn_url: | |
| sys.exit(1) | |
| # Generate email | |
| print(f"\n{'=' * 80}") | |
| print(" Vote Email Template") | |
| print(f"{'=' * 80}\n") | |
| generate_email_template(package_name, version, rc_num, svn_url) | |
| print("\n" + "=" * 80) | |
| print(" Process Complete!") | |
| print("=" * 80) | |
| print("\nNext steps:") | |
| print(f"1. Push the git tag: git push origin {tag_name}") | |
| print("2. Copy the email template above and send to dev@hamilton.apache.org") | |
| print("3. Wait for votes (minimum 72 hours)") | |
| print("\n") | |
| if __name__ == "__main__": | |
| main() |