blob: 70a8fdd7b4e6ff3aa7a71d8408f5eb9ca9aaa6ae [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 Juneau Release Script
This script automates the release process for Apache Juneau, including:
- Prerequisite checks
- Maven repository cleanup
- Git repository cloning
- Build and verification
- Test workspace creation
- Maven deploy and release
- Binary artifact creation
- SVN distribution upload
The script supports resuming from any step, allowing you to restart at arbitrary points
in the release process.
Usage:
python3 scripts/release.py [--start-step STEP_NAME] [--list-steps] [--skip-step STEP_NAME] [--resume]
Options:
--start-step STEP_NAME Start execution from the specified step (skips all previous steps)
--list-steps List all available steps and exit
--skip-step STEP_NAME Skip a specific step (can be used multiple times)
--resume Resume from the last checkpoint (if available)
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from pathlib import Path
# Constants for duplicated string literals
POM_XML = 'pom.xml'
STAGING_DIR = '~/tmp/dist-release-juneau'
SVN_DIST_URL = 'https://dist.apache.org/repos/dist/dev/juneau'
RC_PATTERN = r'RC(\d+)'
from typing import Dict, Optional, List
# Minimum required versions
MIN_JAVA_VERSION = 17
MIN_MAVEN_VERSION = 3
# State file for checkpoint/resume functionality
STATE_FILE = Path.home() / '.juneau-release-state.json'
class ReleaseState:
"""Manages the release state for checkpoint/resume functionality."""
def __init__(self):
self.state_file = STATE_FILE
self.data = self._load()
def _load(self) -> Dict:
"""Load state from file."""
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Warning: Could not load state file: {e}")
return {}
def save(self):
"""Save current state to file."""
try:
with open(self.state_file, 'w') as f:
json.dump(self.data, f, indent=2)
except Exception as e:
print(f"Warning: Could not save state file: {e}")
def get(self, key: str, default=None):
"""Get a state value."""
return self.data.get(key, default)
def set(self, key: str, value):
"""Set a state value."""
self.data[key] = value
self.save()
def clear(self):
"""Clear all state."""
self.data = {}
if self.state_file.exists():
self.state_file.unlink()
def get_last_step(self) -> Optional[str]:
"""Get the last completed step."""
return self.data.get('last_step')
def set_last_step(self, step: str):
"""Set the last completed step."""
self.set('last_step', step)
class ReleaseScript:
"""Main release script class."""
def __init__(self, rc: Optional[int] = None, start_step: Optional[str] = None, skip_steps: List[str] = None, resume: bool = False, load_env: bool = True):
self.state = ReleaseState()
self.rc = rc # Will be set during _load_env if not provided
self.start_step = start_step
self.skip_steps = set(skip_steps or [])
self.resume = resume
self.start_time = None
self.step_times = {}
# Load environment variables (only if not just listing steps)
if load_env:
self._load_env()
# Define all steps in order
self.steps = [
'check_prerequisites',
'check_java_version',
'check_maven_version',
'clean_maven_repo',
'make_git_folder',
'clone_juneau',
'configure_git',
'run_clean_verify',
'run_deploy',
'run_release_prepare',
'run_release_perform',
'create_binary_artifacts',
'verify_distribution',
]
def _get_version_from_pom(self, pom_path: Path) -> str:
"""Extract version from pom.xml using current-release.py script."""
script_dir = Path(__file__).parent
current_release_script = script_dir / 'current-release.py'
try:
result = subprocess.run(
[sys.executable, str(current_release_script)],
cwd=pom_path.parent,
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except Exception as e:
self.fail(f"Could not determine version using current-release.py: {e}")
def _increment_maintenance_version(self, version: str) -> str:
"""Increment the maintenance version (e.g., 9.0.0 -> 9.0.1)."""
# Match version pattern: major.minor.maintenance
match = re.match(r'^(\d+)\.(\d+)\.(\d+)$', version)
if not match:
self.fail(f"Invalid version format: {version}. Expected format: X.Y.Z")
major, minor, maintenance = map(int, match.groups())
next_version = f"{major}.{minor}.{maintenance + 1}-SNAPSHOT"
return next_version
def _load_history(self, version: str) -> Dict:
"""Load history file for the given version."""
script_dir = Path(__file__).parent
history_file = script_dir / f'release-history-{version}.json'
if history_file.exists():
try:
with open(history_file, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Warning: Could not load history file: {e}")
return {}
def _save_history(self, version: str, values: Dict):
"""Save history file for the given version."""
script_dir = Path(__file__).parent
history_file = script_dir / f'release-history-{version}.json'
try:
# Add last-run date
history_data = values.copy()
history_data['last_run_date'] = datetime.now().isoformat()
with open(history_file, 'w') as f:
json.dump(history_data, f, indent=2)
except Exception as e:
print(f"Warning: Could not save history file: {e}")
def _prompt_with_default(self, prompt: str, default: Optional[str] = None, required: bool = True) -> str:
"""Prompt user for input with a default value."""
if default:
full_prompt = f"{prompt} [{default}]: "
else:
full_prompt = f"{prompt}: "
while True:
response = input(full_prompt).strip()
if response:
return response
elif default:
return default
elif not required:
return ""
else:
print("This field is required. Please enter a value.")
def _load_env(self):
"""Initialize environment variables from pom.xml and user prompts."""
# Get the Juneau root directory
script_dir = Path(__file__).parent
juneau_root = script_dir.parent
pom_path = juneau_root / POM_XML
if not pom_path.exists():
self.fail(f"pom.xml not found at {pom_path}")
# Get version from pom.xml
version = self._get_version_from_pom(pom_path)
next_version = self._increment_maintenance_version(version)
# Load history for this version
history = self._load_history(version)
# Check if this is the first run for this version (determines X_CLEANM2)
is_first_run = not history
print('\n' + '=' * 79)
print('Apache Juneau Release Configuration')
print('=' * 79)
print(f"Detected version from pom.xml: {version}")
print(f"Calculated next version: {next_version}")
if history.get('last_run_date'):
last_run = datetime.fromisoformat(history['last_run_date'])
print(f"Last run: {last_run.strftime('%Y-%m-%d %H:%M:%S')}")
else:
print("Last run: Never (first run for this version)")
print('=' * 79 + '\n')
# Prompt for RC number if not already set
if self.rc is None:
# Try to get default from history
default_rc = None
if history.get('X_RELEASE_CANDIDATE'):
rc_match = re.search(RC_PATTERN, history.get('X_RELEASE_CANDIDATE', ''))
if rc_match:
default_rc = rc_match.group(1)
rc_prompt = "Release candidate number"
if default_rc:
rc_prompt += f" [{default_rc}]"
rc_prompt += ": "
while True:
rc_input = input(rc_prompt).strip()
if rc_input:
try:
self.rc = int(rc_input)
break
except ValueError:
print("Please enter a valid number.")
elif default_rc:
self.rc = int(default_rc)
break
else:
print("Release candidate number is required. Please enter a value.")
release_candidate = f"RC{self.rc}"
staging = self._prompt_with_default(
"Staging directory",
history.get('X_STAGING', STAGING_DIR),
required=True
)
username = self._prompt_with_default(
"Apache username",
history.get('X_USERNAME', ''),
required=True
)
email = self._prompt_with_default(
"Apache email",
history.get('X_EMAIL', ''),
required=True
)
git_branch = self._prompt_with_default(
"Git branch",
history.get('X_GIT_BRANCH', 'master'),
required=True
)
java_home = self._prompt_with_default(
"JAVA_HOME",
history.get('X_JAVA_HOME', os.environ.get('JAVA_HOME', '')),
required=True
)
# Set environment variables
os.environ['X_VERSION'] = version
os.environ['X_NEXT_VERSION'] = next_version
os.environ['X_RELEASE'] = f"juneau-{version}-{release_candidate}"
os.environ['X_STAGING'] = staging
# Save X_RELEASE to state for later retrieval
self.state.set('X_RELEASE', os.environ['X_RELEASE'])
os.environ['X_USERNAME'] = username
os.environ['X_EMAIL'] = email
os.environ['X_GIT_BRANCH'] = git_branch
os.environ['X_JAVA_HOME'] = java_home
os.environ['X_CLEANM2'] = 'Y' if is_first_run else 'N'
# Set JAVA_HOME and PATH
if java_home:
os.environ['JAVA_HOME'] = java_home
os.environ['PATH'] = f"{java_home}/bin:{os.environ.get('PATH', '')}"
# Save history for next time
history_values = {
'X_VERSION': version,
'X_RELEASE_CANDIDATE': release_candidate,
'X_STAGING': staging,
'X_USERNAME': username,
'X_EMAIL': email,
'X_GIT_BRANCH': git_branch,
'X_JAVA_HOME': java_home,
}
self._save_history(version, history_values)
# Display settings
print('\n--- Settings ------------------------------------------------------------------')
for key in ['X_VERSION', 'X_NEXT_VERSION', 'X_RELEASE', 'X_STAGING',
'X_USERNAME', 'X_EMAIL', 'X_CLEANM2', 'X_GIT_BRANCH', 'X_JAVA_HOME']:
value = os.environ.get(key, 'NOT SET')
print(f"{key}: {value}")
print('--------------------------------------------------------------------------------\n')
def message(self, msg: str):
"""Print a formatted message."""
timestamp = datetime.now().strftime('%H:%M:%S')
print('\n' + '-' * 79)
print(f"[{timestamp}] {msg}")
print('-' * 79)
def fail(self, msg: str = None):
"""Fail with a message."""
if msg:
self.message(f"FAILED: {msg}")
print('\n' + '=' * 79)
print('***** FAILED ******************************************************************')
print('=' * 79 + '\n')
sys.exit(1)
def success(self):
"""Success message."""
print('\n' + '=' * 79)
print('***** SUCCESS *****************************************************************')
print('=' * 79 + '\n')
self.state.clear()
sys.exit(0)
def yprompt(self, prompt: str) -> bool:
"""Yes/no prompt. Returns True if yes, False if no."""
print()
response = input(f"{prompt} (Y/n): ").strip()
if response and response.lower() not in ['y', 'yes']:
return False
return True
def run_command(self, cmd: List[str], cwd: Optional[Path] = None, check: bool = True,
capture_output: bool = False) -> subprocess.CompletedProcess:
"""Run a shell command."""
print(f"Running: {' '.join(cmd)}")
if cwd:
print(f" (in directory: {cwd})")
result = subprocess.run(
cmd,
cwd=cwd,
check=False,
capture_output=capture_output,
text=True
)
if check and result.returncode != 0:
self.fail(f"Command failed: {' '.join(cmd)}")
return result
def start_timer(self):
"""Start timing a step."""
self.start_time = time.time()
def end_timer(self) -> float:
"""End timing and return elapsed seconds."""
if self.start_time:
elapsed = time.time() - self.start_time
print(f"Execution time: {elapsed:.1f}s")
self.start_time = None
return elapsed
return 0
def should_run_step(self, step_name: str) -> bool:
"""Determine if a step should run based on start_step and skip_steps."""
if step_name in self.skip_steps:
return False
if self.resume:
last_step = self.state.get_last_step()
if last_step:
# Find the index of last_step and step_name
try:
last_idx = self.steps.index(last_step)
step_idx = self.steps.index(step_name)
# Only run if this step comes after the last completed step
return step_idx > last_idx
except ValueError:
pass
if self.start_step:
try:
start_idx = self.steps.index(self.start_step)
step_idx = self.steps.index(step_name)
# Only run if this step is at or after the start step
return step_idx >= start_idx
except ValueError:
self.fail(f"Unknown start step: {self.start_step}")
return True
def check_prerequisites(self):
"""Check that required tools are available."""
self.message("Checking prerequisites")
required_tools = {
'wget': 'wget',
'gpg': 'gpg',
'svn': 'svn',
'git': 'git',
'java': 'Java',
'mvn': 'Maven',
}
missing = []
for cmd, name in required_tools.items():
result = subprocess.run(['which', cmd], capture_output=True)
if result.returncode != 0:
missing.append(name)
if missing:
self.fail(f"Missing required tools: {', '.join(missing)}")
# Set GPG_TTY
tty = subprocess.run(['tty'], capture_output=True, text=True)
if tty.returncode == 0:
os.environ['GPG_TTY'] = tty.stdout.strip()
self.state.set_last_step('check_prerequisites')
def check_java_version(self):
"""Check Java version."""
self.message("Checking Java version")
# Run java -version and capture stderr (version info goes to stderr)
result = subprocess.run(
['java', '-version'],
capture_output=True,
text=True,
check=False
)
# Print the version output for user visibility
if result.stderr:
print(result.stderr)
# Parse version from stderr
version_text = result.stderr or result.stdout
java_version = self._parse_java_version(version_text)
if java_version is None:
self.fail("Could not determine Java version from output")
if java_version < MIN_JAVA_VERSION:
self.fail(f"Java version {java_version} detected. Java {MIN_JAVA_VERSION} or higher is required.")
print(f"✅ Java version {java_version} detected (Java {MIN_JAVA_VERSION}+ required)")
self.state.set_last_step('check_java_version')
def _parse_java_version(self, version_text: str) -> Optional[int]:
"""Parse Java version from java -version output."""
# Try to find version patterns like:
# - "openjdk version "17.0.1""
# - "java version "17.0.1""
# - "openjdk version "1.8.0_292"" (Java 8)
# - "java version "1.8.0_292"" (Java 8)
# Pattern 1: Modern format (Java 9+): "version "17.0.1""
match = re.search(r'version\s+"(\d+)\.(\d+)', version_text)
if match:
major = int(match.group(1))
minor = int(match.group(2))
# If major is 1, it's old format (Java 8 and below)
# The actual version is in the minor number
if major == 1:
return minor
else:
# Java 9+ uses the major version directly
return major
# Pattern 2: Try to find just a version number
match = re.search(r'(\d+)\.(\d+)', version_text)
if match:
major = int(match.group(1))
minor = int(match.group(2))
if major == 1:
return minor
else:
return major
return None
def check_maven_version(self):
"""Check Maven version."""
self.message("Checking Maven version")
# Run mvn -version and capture output
result = subprocess.run(
['mvn', '-version'],
capture_output=True,
text=True,
check=False
)
# Print the version output for user visibility
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr)
# Get Maven version using maven-version.py script
maven_version = self._get_maven_version()
if maven_version is None:
self.fail("Could not determine Maven version")
if maven_version < MIN_MAVEN_VERSION:
self.fail(f"Maven version {maven_version} detected. Maven {MIN_MAVEN_VERSION} or higher is required.")
print(f"✅ Maven version {maven_version} detected (Maven {MIN_MAVEN_VERSION}+ required)")
self.state.set_last_step('check_maven_version')
def _get_maven_version(self) -> Optional[int]:
"""Get Maven version using maven-version.py script."""
script_dir = Path(__file__).parent
maven_version_script = script_dir / 'maven-version.py'
try:
result = subprocess.run(
[sys.executable, str(maven_version_script)],
capture_output=True,
text=True,
check=True
)
return int(result.stdout.strip())
except Exception as e:
print(f"Warning: Could not determine Maven version using maven-version.py: {e}")
return None
def clean_maven_repo(self):
"""Clean Maven repository."""
self.message("Cleaning Maven repository")
self.start_timer()
clean_m2 = os.environ.get('X_CLEANM2', 'N')
if clean_m2.upper() != 'N':
m2_repo = Path.home() / '.m2' / 'repository'
if m2_repo.exists():
old_repo = Path.home() / '.m2' / 'repository-old'
if old_repo.exists():
shutil.rmtree(old_repo)
m2_repo.rename(old_repo)
# Remove in background
subprocess.Popen(['rm', '-rf', str(old_repo)])
self.end_timer()
self.state.set_last_step('clean_maven_repo')
def make_git_folder(self):
"""Create git staging folder."""
self.message("Making git folder")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
# Clean up entire staging directory to start fresh (avoids issues from previous runs)
if staging.exists():
print(f" Removing existing staging directory: {staging}")
shutil.rmtree(staging)
staging.mkdir(parents=True, exist_ok=True)
git_dir = staging / 'git'
git_dir.mkdir(parents=True)
self.end_timer()
self.state.set_last_step('make_git_folder')
def clone_juneau(self):
"""Clone juneau.git repository."""
self.message("Cloning juneau.git")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
git_dir = staging / 'git'
juneau_dir = git_dir / 'juneau'
# Since we clean up the staging directory in make_git_folder, this should always be a fresh clone
self.run_command(
['git', 'clone', 'https://gitbox.apache.org/repos/asf/juneau.git'],
cwd=git_dir
)
self.end_timer()
self.state.set_last_step('clone_juneau')
def configure_git(self):
"""Configure git user name and email."""
self.message("Configuring git")
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
username = os.environ.get('X_USERNAME')
email = os.environ.get('X_EMAIL')
if username:
self.run_command(['git', 'config', 'user.name', username], cwd=juneau_dir)
if email:
self.run_command(['git', 'config', 'user.email', email], cwd=juneau_dir)
self.state.set_last_step('configure_git')
def run_clean_verify(self):
"""Run Maven clean verify."""
self.message("Running clean verify")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
self.run_command(['mvn', 'clean', 'verify'], cwd=juneau_dir)
self.end_timer()
self.state.set_last_step('run_clean_verify')
def create_test_workspace(self):
"""Create test workspace."""
self.message("Creating test workspace")
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
workspace = juneau_dir / 'target' / 'workspace'
version = os.environ.get('X_VERSION', '')
version_snapshot = f"{version}-SNAPSHOT"
if workspace.exists():
shutil.rmtree(workspace)
workspace.mkdir(parents=True)
zip_files = [
('juneau-microservice/juneau-my-jetty-microservice/target/my-jetty-microservice-{}-bin.zip',
'my-jetty-microservice'),
('juneau-microservice/juneau-my-springboot-microservice/target/my-springboot-microservice-{}-bin.zip',
'my-springboot-microservice'),
('juneau-examples/juneau-examples-core/target/juneau-examples-core-{}-bin.zip',
'juneau-examples-core'),
('juneau-examples/juneau-examples-rest-jetty/target/juneau-examples-rest-jetty-{}-bin.zip',
'juneau-examples-rest-jetty'),
('juneau-examples/juneau-examples-rest-springboot/target/juneau-examples-rest-springboot-{}-bin.zip',
'juneau-examples-rest-springboot'),
]
for zip_src_pattern, zip_tgt_name in zip_files:
zip_src = juneau_dir / zip_src_pattern.format(version_snapshot)
zip_tgt = workspace / zip_tgt_name
if not zip_src.exists():
print(f"Warning: {zip_src} not found, skipping")
continue
print(f"Unzipping {zip_src} to {zip_tgt}")
self.run_command(['unzip', '-o', str(zip_src), '-d', str(zip_tgt)], check=False)
workspace_path = staging / 'git' / 'juneau' / 'target' / 'workspace'
if not self.yprompt(f"Can all workspace projects in {workspace_path} be cleanly imported as Maven projects into Eclipse?"):
self.fail("Workspace verification failed")
self.state.set_last_step('create_test_workspace')
def run_deploy(self):
"""Run Maven deploy."""
self.message("Running deploy")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
self.run_command(['mvn', 'deploy', '-Daether.checksums.algorithms=MD5,SHA-1,SHA-512'], cwd=juneau_dir)
self.end_timer()
self.state.set_last_step('run_deploy')
def run_release_prepare(self):
"""Run Maven release:prepare."""
self.message("Running release:prepare")
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
version = os.environ.get('X_VERSION')
release = os.environ.get('X_RELEASE')
next_version = os.environ.get('X_NEXT_VERSION')
# Check that the current POM version is a SNAPSHOT (required by release:prepare)
pom_path = juneau_dir / POM_XML
if pom_path.exists():
# Read version directly from POM (don't use current-release.py as it strips SNAPSHOT)
try:
import xml.etree.ElementTree as ET
tree = ET.parse(pom_path)
root = tree.getroot()
# Handle namespace
ns_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
if ns_uri:
ns = {'maven': ns_uri}
version_elem = root.find('maven:version', ns)
else:
version_elem = root.find('version')
if version_elem is not None and version_elem.text:
current_version = version_elem.text.strip()
else:
self.fail("Could not find version element in pom.xml")
except Exception as e:
self.fail(f"Could not parse version from pom.xml: {e}")
if not current_version.endswith('-SNAPSHOT'):
self.fail(
f"Current version in POM is '{current_version}', but release:prepare requires a SNAPSHOT version.\n"
f"This usually means a previous release attempt already changed the version.\n"
f"Please ensure the git repository is in the correct state (e.g., checkout the master branch)."
)
print(f"✓ Current POM version is SNAPSHOT: {current_version}")
# run_command will automatically fail if the command doesn't succeed (check=True by default)
self.run_command([
'mvn', 'release:prepare',
'-DautoVersionSubmodules=true',
f'-DreleaseVersion={version}',
f'-Dtag={release}',
f'-DdevelopmentVersion={next_version}'
], cwd=juneau_dir)
# If we get here, the command succeeded
print("✅ release:prepare completed successfully")
self.state.set_last_step('run_release_prepare')
def run_git_diff(self):
"""Run git diff."""
self.message("Running git diff")
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
release = os.environ.get('X_RELEASE')
self.run_command(['git', 'diff', release], cwd=juneau_dir, check=False)
self.state.set_last_step('run_git_diff')
def run_release_perform(self):
"""Run Maven release:perform."""
self.message("Running release:perform")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
self.run_command(['mvn', 'release:perform', '-Daether.checksums.algorithms=MD5,SHA-1,SHA-512'], cwd=juneau_dir)
# Open Nexus staging repositories page
subprocess.Popen(['open', 'https://repository.apache.org/#stagingRepositories'])
print("\nOn Apache's Nexus instance, locate the staging repository for the code you just released.")
print("It should be called something like orgapachejuneau-1000.")
print("Check the Updated time stamp and click to verify its Content.")
print("IMPORTANT - When all artifacts to be deployed are in the staging repository, tick the box next to it and click Close.")
print("DO NOT CLICK RELEASE YET - the release candidate must pass [VOTE] emails on dev@juneau before we release.")
print("Once closing has finished (check with Refresh), browse to the URL of the staging repository which should be something like https://repository.apache.org/content/repositories/orgapachejuneau-1000.")
print()
repo_input = input("Enter the staging repository name AFTER CLOSING IT!!!: orgapachejuneau-").strip()
repo_name = f"orgapachejuneau-{repo_input}"
if not self.yprompt(f"X_REPO = {repo_name}. Is this correct?"):
self.fail("Repository name confirmation failed")
os.environ['X_REPO'] = repo_name
self.state.set('X_REPO', repo_name)
self.end_timer()
self.state.set_last_step('run_release_perform')
def create_binary_artifacts(self):
"""Create binary artifacts and upload to SVN."""
self.message("Creating binary artifacts")
self.start_timer()
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
version = os.environ.get('X_VERSION')
release = os.environ.get('X_RELEASE')
repo = self.state.get('X_REPO') or os.environ.get('X_REPO')
if not repo:
self.fail("X_REPO not set. Did you complete the release:perform step?")
# Checkout SVN dist
dist_dir = staging / 'dist'
if dist_dir.exists():
shutil.rmtree(dist_dir)
self.run_command(['svn', 'checkout', SVN_DIST_URL, 'dist'], cwd=staging)
# Remove old files
source_dir = dist_dir / 'source'
binaries_dir = dist_dir / 'binaries'
if source_dir.exists():
self.run_command(['svn', 'rm', 'source/*'], cwd=dist_dir, check=False)
if binaries_dir.exists():
self.run_command(['svn', 'rm', 'binaries/*'], cwd=dist_dir, check=False)
# Create release directories
release_source_dir = source_dir / release
release_binaries_dir = binaries_dir / release
release_source_dir.mkdir(parents=True)
release_binaries_dir.mkdir(parents=True)
# Download source artifacts
repo_url = f"https://repository.apache.org/content/repositories/{repo}/org/apache/juneau/"
self.run_command([
'wget', '-e', 'robots=off', '--recursive', '--no-parent', '--no-directories',
'-A', '*-source-release*', repo_url
], cwd=release_source_dir)
# Rename source zip file
source_zip = release_source_dir / f"juneau-{version}-source-release.zip"
if source_zip.exists():
target_zip = release_source_dir / f"apache-juneau-{version}-src.zip"
# Simply rename the zip file (docs is already excluded by maven-source-plugin)
print(f"Renaming source zip: {source_zip.name} -> {target_zip.name}")
source_zip.rename(target_zip)
print(f"✓ Source zip renamed to {target_zip.name}")
# Process .asc file
asc_file = release_source_dir / f"juneau-{version}-source-release.zip.asc"
if asc_file.exists():
asc_file.rename(release_source_dir / f"apache-juneau-{version}-src.zip.asc")
# Process .sha512 file (copy as-is, same as .asc)
sha512_file = release_source_dir / f"juneau-{version}-source-release.zip.sha512"
if sha512_file.exists():
sha512_file.rename(release_source_dir / f"apache-juneau-{version}-src.zip.sha512")
# Remove old hash files
for old_hash in release_source_dir.glob('*.sha1'):
old_hash.unlink()
for old_hash in release_source_dir.glob('*.md5'):
old_hash.unlink()
# Download binary artifacts
self.run_command([
'wget', '-e', 'robots=off', '--recursive', '--no-parent', '--no-directories',
'-A', 'juneau-distrib*-bin.zip*', repo_url
], cwd=release_binaries_dir)
# Rename and process binary files
bin_zip = release_binaries_dir / f"juneau-distrib-{version}-bin.zip"
if bin_zip.exists():
target_bin = release_binaries_dir / f"apache-juneau-{version}-bin.zip"
bin_zip.rename(target_bin)
# Process .asc file
bin_asc = release_binaries_dir / f"juneau-distrib-{version}-bin.zip.asc"
if bin_asc.exists():
bin_asc.rename(release_binaries_dir / f"apache-juneau-{version}-bin.zip.asc")
# Process .sha512 file (copy as-is, same as .asc)
bin_sha512 = release_binaries_dir / f"juneau-distrib-{version}-bin.zip.sha512"
if bin_sha512.exists():
bin_sha512.rename(release_binaries_dir / f"apache-juneau-{version}-bin.zip.sha512")
# Remove old hash files
for old_hash in release_binaries_dir.glob('*.sha1'):
old_hash.unlink()
for old_hash in release_binaries_dir.glob('*.md5'):
old_hash.unlink()
# Add and commit to SVN
self.run_command(['svn', 'add', f'source/{release}'], cwd=dist_dir)
self.run_command(['svn', 'add', f'binaries/{release}'], cwd=dist_dir)
self.run_command(['svn', 'commit', '-m', release], cwd=dist_dir)
self.end_timer()
self.state.set_last_step('create_binary_artifacts')
def verify_distribution(self):
"""Verify distribution files are available."""
self.message("Verifying distribution")
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
version = os.environ.get('X_VERSION')
release = os.environ.get('X_RELEASE')
# Checkout or update SVN dist to verify files are available
dist_dir = staging / 'dist'
if dist_dir.exists():
# Update existing checkout
self.run_command(['svn', 'update'], cwd=dist_dir, check=False)
else:
# Fresh checkout
self.run_command(['svn', 'checkout', SVN_DIST_URL, 'dist'], cwd=staging)
# Expected files
source_dir = dist_dir / 'source' / release
binaries_dir = dist_dir / 'binaries' / release
expected_files = [
# Source files
source_dir / f"apache-juneau-{version}-src.zip",
source_dir / f"apache-juneau-{version}-src.zip.asc",
source_dir / f"apache-juneau-{version}-src.zip.sha512",
# Binary files
binaries_dir / f"apache-juneau-{version}-bin.zip",
binaries_dir / f"apache-juneau-{version}-bin.zip.asc",
binaries_dir / f"apache-juneau-{version}-bin.zip.sha512",
]
# Verify all files exist and are not empty
missing_files = []
empty_files = []
for file_path in expected_files:
if not file_path.exists():
missing_files.append(str(file_path.relative_to(dist_dir)))
elif file_path.stat().st_size == 0:
empty_files.append(str(file_path.relative_to(dist_dir)))
if missing_files:
self.fail("Missing distribution files:\n " + "\n ".join(missing_files))
if empty_files:
self.fail("Empty distribution files:\n " + "\n ".join(empty_files))
# All files verified
print("\n✅ All distribution files verified:")
for file_path in expected_files:
size = file_path.stat().st_size
size_mb = size / (1024 * 1024)
print(f" ✓ {file_path.relative_to(dist_dir)} ({size_mb:.2f} MB)")
# Open browser for manual inspection
subprocess.Popen(['open', SVN_DIST_URL])
print("\n✅ Distribution verification successful. Voting can be started.")
# Generate vote email
self._generate_vote_email(version, release, dist_dir)
self.state.set_last_step('verify_distribution')
def _calculate_vote_end_date(self) -> str:
"""Calculate vote end date (72 hours from now, minimum 3 days)."""
# Add 72 hours (3 days) to current time
end_date = datetime.now() + timedelta(hours=72)
# Format: "04-May-2016 1:30pm"
formatted = end_date.strftime('%d-%b-%Y %I:%M%p').lstrip('0')
# Convert to lowercase and fix spacing
formatted = formatted.replace(' 0', ' ').lower()
return formatted
def _read_sha512_from_url(self, url: str) -> Optional[str]:
"""Read SHA-512 checksum from Apache distribution URL and return formatted checksum."""
try:
with urllib.request.urlopen(url) as response:
content = response.read().decode('utf-8').strip()
# Parse the formatted checksum from the file
# Format: "/path/to/file:\nCHECKSUM_LINE_1\nCHECKSUM_LINE_2"
lines = [line.strip() for line in content.split('\n') if line.strip()]
if len(lines) >= 2:
# First line is the path, rest are the checksum
# Join checksum lines with newline to preserve formatting
checksum = '\n'.join(lines[1:])
return checksum
elif len(lines) == 1:
# Single line format, extract just the checksum part
parts = lines[0].split(':')
if len(parts) > 1:
# Format: "path: checksum"
return parts[1].strip()
return lines[0]
except Exception as e:
print(f"Warning: Could not read SHA-512 from {url}: {e}")
return None
def _get_git_commit_hash(self, release_tag: str) -> Optional[str]:
"""Get git commit hash for a release tag."""
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
juneau_dir = staging / 'git' / 'juneau'
try:
result = subprocess.run(
['git', 'rev-parse', release_tag],
cwd=juneau_dir,
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except Exception as e:
print(f"Warning: Could not get git commit hash for tag {release_tag}: {e}")
return None
def _generate_vote_email(self, version: str, release: str, dist_dir: Path):
"""Generate vote email body for the release."""
# Read SHA-512 checksums from Apache distribution URLs
src_sha512_url = f"https://dist.apache.org/repos/dist/dev/juneau/source/{release}/apache-juneau-{version}-src.zip.sha512"
bin_sha512_url = f"https://dist.apache.org/repos/dist/dev/juneau/binaries/{release}/apache-juneau-{version}-bin.zip.sha512"
src_sha512 = self._read_sha512_from_url(src_sha512_url) or "SHA512_NOT_FOUND"
bin_sha512 = self._read_sha512_from_url(bin_sha512_url) or "SHA512_NOT_FOUND"
# Get git commit hash
git_commit = self._get_git_commit_hash(release) or "COMMIT_HASH_NOT_FOUND"
# Get staging repository
repo = self.state.get('X_REPO') or os.environ.get('X_REPO') or "REPO_NOT_FOUND"
# Calculate vote end date
vote_end_date = self._calculate_vote_end_date()
# Extract RC number from release (e.g., "juneau-9.2.0-RC1" -> "RC1")
rc_match = re.search(RC_PATTERN, release)
rc_number = rc_match.group(1) if rc_match else "x"
# Generate email body
email_body = f"""To: dev@juneau.apache.org
[VOTE] Release Apache Juneau {version} RC{rc_number}
I am pleased to be calling this vote for the source release of Apache Juneau {version} RC{rc_number}.
The binaries are available at:
https://dist.apache.org/repos/dist/dev/juneau/binaries/{release}/
The release candidate to be voted over is available at:
https://dist.apache.org/repos/dist/dev/juneau/source/{release}/
SHA-512 checksums:
apache-juneau-{version}-src.zip:
{src_sha512}
apache-juneau-{version}-bin.zip:
{bin_sha512}
Build the release candidate using:
mvn clean install
The release candidate is signed with a GPG key available at:
https://dist.apache.org/repos/dist/release/juneau/KEYS
A staged Maven repository is available for review at:
https://repository.apache.org/content/repositories/{repo}/
The Git commit for this release is...
https://gitbox.apache.org/repos/asf?p=juneau.git;a=commit;h={git_commit}
Please vote on releasing this package as:
Apache Juneau {version}
This vote will be open until {vote_end_date} and passes if a majority of at least three +1 Apache Juneau PMC votes are cast.
(needs to be at least 72 weekday hours)
[ ] +1 Release this package
[ ] 0 I don't feel strongly about it, but don't object
[ ] -1 Do not release this package because...
Anyone can participate in testing and voting, not just committers, please feel free to try out the release candidate and provide your votes.
"""
# Display email to console
print("\n" + "=" * 79)
print("VOTE EMAIL BODY:")
print("=" * 79)
print(email_body)
print("=" * 79)
def list_steps(self):
"""List all available steps."""
print("\nAvailable steps:")
for i, step in enumerate(self.steps, 1):
print(f" {i:2d}. {step}")
print()
def revert_release(self):
"""Revert a release by deleting the tag, reverting Maven versions, and cleaning up SVN files."""
self.message("Reverting release")
self.start_timer()
# Get version and release from state or environment
version = self.state.get('X_VERSION') or os.environ.get('X_VERSION')
release = self.state.get('X_RELEASE') or os.environ.get('X_RELEASE')
if not version or not release:
self.fail("X_VERSION and X_RELEASE must be set. Cannot determine what to revert.")
print(f"Version: {version}")
print(f"Release: {release}")
# Confirm with user
if not self.yprompt(f"Are you sure you want to revert release {release}? This will delete the git tag and clean up SVN files."):
print("Revert cancelled.")
return
staging = Path(os.environ.get('X_STAGING', STAGING_DIR)).expanduser()
git_dir = staging / 'git' / 'juneau'
# Pull latest changes from git
print("\nPulling latest changes from git...")
if git_dir.exists() and (git_dir / '.git').exists():
try:
self.run_command(['git', 'pull'], cwd=git_dir)
print(" ✓ Git pull completed")
except Exception as e:
print(f" Warning: Could not pull from git: {e}")
else:
print(f" Warning: Git directory not found at {git_dir}, skipping git pull")
# Step 1: Delete the git tag
print("\nStep 1: Deleting git tag...")
tag_name = release # e.g., "juneau-9.2.0-RC2"
if git_dir.exists() and (git_dir / '.git').exists():
try:
# Check if tag exists locally
result = subprocess.run(
['git', 'tag', '-l', tag_name],
cwd=git_dir,
capture_output=True,
text=True,
check=False
)
if result.stdout.strip():
print(f" Found local tag: {tag_name}")
self.run_command(['git', 'tag', '-d', tag_name], cwd=git_dir, check=False)
# Delete remote tag
print(f" Deleting remote tag: {tag_name}")
self.run_command(['git', 'push', 'origin', f':{tag_name}'], cwd=git_dir, check=False)
print(" ✓ Git tag deleted")
except Exception as e:
print(f" Warning: Could not delete git tag: {e}")
else:
print(f" Warning: Git directory not found at {git_dir}, skipping tag deletion")
# Step 2: Revert Maven versions
print("\nStep 2: Reverting Maven versions...")
development_version = f"{version}-SNAPSHOT"
if git_dir.exists() and (git_dir / POM_XML).exists():
try:
print(f" Reverting to development version: {development_version}")
self.run_command([
'mvn', 'release:update-versions',
'-DautoVersionSubmodules=true',
f'-DdevelopmentVersion={development_version}'
], cwd=git_dir)
print(" ✓ Maven versions reverted")
except Exception as e:
print(f" Warning: Could not revert Maven versions: {e}")
else:
print(f" Warning: Git directory or pom.xml not found at {git_dir}, skipping Maven version revert")
# Step 3: Clean up SVN files
print("\nStep 3: Cleaning up SVN files...")
dist_dir = staging / 'dist'
if dist_dir.exists() and (dist_dir / '.svn').exists():
try:
# Update SVN first
self.run_command(['svn', 'update'], cwd=dist_dir, check=False)
# Find and remove RC directories
binaries_dir = dist_dir / 'binaries'
source_dir = dist_dir / 'source'
removed_any = False
# Remove from binaries
if binaries_dir.exists():
for item in binaries_dir.iterdir():
if item.is_dir() and 'RC' in item.name:
print(f" Removing: binaries/{item.name}")
self.run_command(['svn', 'rm', str(item.relative_to(dist_dir))], cwd=dist_dir, check=False)
removed_any = True
# Remove from source
if source_dir.exists():
for item in source_dir.iterdir():
if item.is_dir() and 'RC' in item.name:
print(f" Removing: source/{item.name}")
self.run_command(['svn', 'rm', str(item.relative_to(dist_dir))], cwd=dist_dir, check=False)
removed_any = True
if removed_any:
# Check status
result = subprocess.run(
['svn', 'status'],
cwd=dist_dir,
capture_output=True,
text=True,
check=False
)
if result.stdout and 'D' in result.stdout:
print("\n SVN changes ready to commit:")
print(result.stdout)
if self.yprompt("Commit SVN deletions?"):
self.run_command(['svn', 'commit', '-m', f'Remove {release} release candidate'], cwd=dist_dir)
print(" ✓ SVN files cleaned up and committed")
else:
print(" SVN deletions staged but not committed")
else:
print(" No SVN changes to commit")
else:
print(" No RC directories found in SVN")
except Exception as e:
print(f" Warning: Could not clean up SVN files: {e}")
else:
print(f" Warning: SVN directory not found at {dist_dir}, skipping SVN cleanup")
self.end_timer()
print("\n" + "=" * 79)
print("✅ Release revert complete!")
print("=" * 79)
print("\nNote: You may need to manually:")
print(" - Reset your local git repository if needed")
print(" - Verify Maven versions were reverted correctly")
print(" - Check SVN repository for any remaining files")
def run(self):
"""Run the release script."""
# Prompt for PGP passphrase early (before any time-consuming operations)
script_dir = Path(__file__).parent
prompt_script = script_dir / 'prompt-pgp-passphrase.py'
if prompt_script.exists():
print("\n" + "=" * 79)
print("Prompting for PGP passphrase...")
print("=" * 79)
try:
subprocess.run(
[sys.executable, str(prompt_script)],
check=False # Don't fail if this doesn't work
)
except Exception as e:
print(f"âš  Could not run PGP passphrase prompt: {e}")
if self.resume:
last_step = self.state.get_last_step()
if last_step:
print(f"Resuming from last checkpoint: {last_step}")
# Find the step after last_step
try:
last_idx = self.steps.index(last_step)
if last_idx < len(self.steps) - 1:
self.start_step = self.steps[last_idx + 1]
print(f"Starting from step: {self.start_step}")
except ValueError:
pass
for step_name in self.steps:
if not self.should_run_step(step_name):
print(f"Skipping step: {step_name}")
continue
step_method = getattr(self, step_name)
try:
step_method()
except KeyboardInterrupt:
print("\n\nScript interrupted by user")
print(f"Last completed step: {step_name}")
print(f"To resume, run: python3 scripts/release.py --start-step {step_name}")
sys.exit(1)
except Exception as e:
print(f"\nError in step {step_name}: {e}")
print(f"To resume, run: python3 scripts/release.py --start-step {step_name}")
raise
self.success()
def main():
parser = argparse.ArgumentParser(
description='Apache Juneau Release Script',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
'--start-step',
help='Start execution from the specified step (skips all previous steps)'
)
parser.add_argument(
'--list-steps',
action='store_true',
help='List all available steps and exit'
)
parser.add_argument(
'--skip-step',
action='append',
default=[],
help='Skip a specific step (can be used multiple times)'
)
parser.add_argument(
'--resume',
action='store_true',
help='Resume from the last checkpoint (if available)'
)
parser.add_argument(
'--revert',
action='store_true',
help='Revert a release by deleting the git tag, reverting Maven versions, and cleaning up SVN files'
)
args = parser.parse_args()
# If just listing steps, don't require RC
if args.list_steps:
# Create a dummy script just to call list_steps (don't load env)
script = ReleaseScript(rc=None, start_step=None, skip_steps=[], resume=False, load_env=False)
script.list_steps()
return
# If reverting, handle it separately
if args.revert:
# Try to determine RC and load state
rc = None
state_data = {}
# Try to extract from X_RELEASE environment variable
x_release = os.environ.get('X_RELEASE')
if x_release:
rc_match = re.search(RC_PATTERN, x_release)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from X_RELEASE: {rc}")
# Try to extract from state file
state_file = STATE_FILE
if state_file.exists():
try:
with open(state_file, 'r') as f:
state_data = json.load(f)
x_release = state_data.get('X_RELEASE')
if x_release and rc is None:
rc_match = re.search(RC_PATTERN, x_release)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from state file: {rc}")
# Set environment variables from state to avoid prompts
if state_data.get('X_VERSION'):
os.environ['X_VERSION'] = state_data['X_VERSION']
if state_data.get('X_RELEASE'):
os.environ['X_RELEASE'] = state_data['X_RELEASE']
if state_data.get('X_STAGING'):
os.environ['X_STAGING'] = state_data['X_STAGING']
except Exception as e:
print(f"Warning: Could not load state file: {e}")
# Get version - from state, environment, or pom.xml
version = state_data.get('X_VERSION') or os.environ.get('X_VERSION')
release = None
if not version:
# Try to get version from pom.xml
script_dir = Path(__file__).parent
juneau_root = script_dir.parent
pom_path = juneau_root / POM_XML
if pom_path.exists():
try:
result = subprocess.run(
["mvn", "help:evaluate", "-Dexpression=project.version", "-q", "-DforceStdout"],
cwd=pom_path.parent,
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
if version.endswith('-SNAPSHOT'):
version = version[:-9]
print(f"📌 Detected version from pom.xml: {version}")
except Exception:
pass
# If we have version but no RC, try to get RC from history file
if version and rc is None:
script_dir = Path(__file__).parent
history_file = script_dir / f'release-history-{version}.json'
if history_file.exists():
try:
with open(history_file, 'r') as f:
history = json.load(f)
release_candidate = history.get('X_RELEASE_CANDIDATE', '')
if release_candidate:
rc_match = re.search(RC_PATTERN, release_candidate)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from history file: {rc}")
except Exception as e:
print(f"Warning: Could not load history file: {e}")
# If we still don't have RC, prompt for it
if version and rc is None:
while True:
rc_input = input("Release candidate number: ").strip()
if rc_input:
try:
rc = int(rc_input)
break
except ValueError:
print("Please enter a valid number.")
else:
print("Release candidate number is required.")
# Construct X_RELEASE if we have version and RC
if version and rc:
release = f"juneau-{version}-RC{rc}"
os.environ['X_RELEASE'] = release
print(f"📌 Constructed release: {release}")
# Set version in environment if we have it
if version:
os.environ['X_VERSION'] = version
# Create script without loading env (we'll set what we need manually)
script = ReleaseScript(rc=rc, start_step=None, skip_steps=[], resume=False, load_env=False)
# Ensure state and environment have the necessary info
if version:
script.state.set('X_VERSION', version)
os.environ['X_VERSION'] = version
if release:
script.state.set('X_RELEASE', release)
os.environ['X_RELEASE'] = release
elif state_data.get('X_RELEASE'):
# Use release from state if we couldn't construct it
release = state_data.get('X_RELEASE')
script.state.set('X_RELEASE', release)
os.environ['X_RELEASE'] = release
if state_data.get('X_STAGING'):
script.state.set('X_STAGING', state_data['X_STAGING'])
os.environ['X_STAGING'] = state_data['X_STAGING']
elif not os.environ.get('X_STAGING'):
# Set default staging if not set
os.environ['X_STAGING'] = STAGING_DIR
script.revert_release()
return
# Try to determine RC from context if resuming (will prompt if not found)
rc = None
if args.resume or args.start_step:
# Try to extract from X_RELEASE environment variable
x_release = os.environ.get('X_RELEASE')
if x_release:
rc_match = re.search(RC_PATTERN, x_release)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from X_RELEASE: {rc}")
# Try to extract from state file
if rc is None:
state_file = STATE_FILE
if state_file.exists():
try:
with open(state_file, 'r') as f:
state = json.load(f)
x_release = state.get('X_RELEASE')
if x_release:
rc_match = re.search(RC_PATTERN, x_release)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from state file: {rc}")
except Exception:
pass
# Try to extract from history files (get latest version's RC)
if rc is None:
script_dir = Path(__file__).parent
juneau_root = script_dir.parent
pom_path = juneau_root / POM_XML
if pom_path.exists():
try:
# Get version from pom
result = subprocess.run(
["mvn", "help:evaluate", "-Dexpression=project.version", "-q", "-DforceStdout"],
cwd=pom_path.parent,
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
if version.endswith('-SNAPSHOT'):
version = version[:-9]
# Load history for this version
history_file = script_dir / f'release-history-{version}.json'
if history_file.exists():
with open(history_file, 'r') as f:
history = json.load(f)
release_candidate = history.get('X_RELEASE_CANDIDATE', '')
rc_match = re.search(RC_PATTERN, release_candidate)
if rc_match:
rc = int(rc_match.group(1))
print(f"📌 Detected RC number from history file: {rc}")
except Exception:
pass
script = ReleaseScript(
rc=rc,
start_step=args.start_step,
skip_steps=args.skip_step,
resume=args.resume
)
script.run()
if __name__ == '__main__':
main()