- Rename old manual release.py to release_old_manual.py - Make simplified setuptools-scm script the new release.py 🚀 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
492 lines
18 KiB
Python
Executable File
492 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
MarkiTect Release Management Tool
|
|
|
|
This script automates the release process for MarkiTect, including:
|
|
- Version management and validation
|
|
- Changelog generation
|
|
- Git tagging and repository management
|
|
- Package building and distribution
|
|
- Release artifact creation
|
|
|
|
Usage:
|
|
python release.py [command] [options]
|
|
|
|
Commands:
|
|
prepare Prepare a new release (bump version, update changelog)
|
|
build Build release packages
|
|
tag Create git tag for release
|
|
publish Publish release (build + tag + distribute)
|
|
status Show current release status
|
|
validate Validate current state for release
|
|
|
|
Options:
|
|
--version VERSION Target version (e.g., 1.0.0, 1.0.1-rc1)
|
|
--pre-release Mark as pre-release
|
|
--dry-run Show what would be done without making changes
|
|
--force Force operation even with warnings
|
|
--help Show help message
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import json
|
|
import subprocess
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
import tempfile
|
|
|
|
|
|
class ReleaseManager:
|
|
"""Manages the MarkiTect release process."""
|
|
|
|
def __init__(self, dry_run=False, force=False):
|
|
self.dry_run = dry_run
|
|
self.force = force
|
|
self.project_root = Path(__file__).parent.absolute()
|
|
self.pyproject_toml = self.project_root / "pyproject.toml"
|
|
self.version_file = self.project_root / "markitect" / "__version__.py"
|
|
self.changelog_file = self.project_root / "CHANGELOG.md"
|
|
|
|
def run_command(self, cmd: List[str], capture=True, check=True, skip_dry_run=False) -> subprocess.CompletedProcess:
|
|
"""Run a command with optional dry-run support."""
|
|
if self.dry_run and not skip_dry_run:
|
|
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
|
|
return subprocess.run(cmd, capture_output=capture, text=True, check=check)
|
|
|
|
def get_current_version(self) -> str:
|
|
"""Get current version from pyproject.toml."""
|
|
with open(self.pyproject_toml, 'r') as f:
|
|
content = f.read()
|
|
|
|
match = re.search(r'version\s*=\s*"([^"]+)"', content)
|
|
if not match:
|
|
raise ValueError("Could not find version in pyproject.toml")
|
|
|
|
return match.group(1)
|
|
|
|
def validate_version(self, version: str) -> bool:
|
|
"""Validate version format (semantic versioning)."""
|
|
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.?(\d+))?$'
|
|
return bool(re.match(pattern, version))
|
|
|
|
def compare_versions(self, v1: str, v2: str) -> int:
|
|
"""Compare two versions. Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2."""
|
|
def version_tuple(v):
|
|
parts = v.split('-')[0].split('.')
|
|
main = tuple(int(x) for x in parts)
|
|
|
|
if '-' in v:
|
|
pre = v.split('-')[1]
|
|
if 'alpha' in pre:
|
|
pre_num = int(re.search(r'(\d+)', pre).group(1)) if re.search(r'(\d+)', pre) else 0
|
|
return main + (0, pre_num)
|
|
elif 'beta' in pre:
|
|
pre_num = int(re.search(r'(\d+)', pre).group(1)) if re.search(r'(\d+)', pre) else 0
|
|
return main + (1, pre_num)
|
|
elif 'rc' in pre:
|
|
pre_num = int(re.search(r'(\d+)', pre).group(1)) if re.search(r'(\d+)', pre) else 0
|
|
return main + (2, pre_num)
|
|
|
|
return main + (3, 0) # Release version
|
|
|
|
t1, t2 = version_tuple(v1), version_tuple(v2)
|
|
if t1 < t2:
|
|
return -1
|
|
elif t1 > t2:
|
|
return 1
|
|
else:
|
|
return 0
|
|
|
|
def update_version(self, new_version: str):
|
|
"""Update version in pyproject.toml and __version__.py."""
|
|
print(f"📝 Updating version to {new_version}")
|
|
|
|
# Update pyproject.toml
|
|
with open(self.pyproject_toml, 'r') as f:
|
|
content = f.read()
|
|
|
|
new_content = re.sub(
|
|
r'version\s*=\s*"[^"]+"',
|
|
f'version = "{new_version}"',
|
|
content
|
|
)
|
|
|
|
if not self.dry_run:
|
|
with open(self.pyproject_toml, 'w') as f:
|
|
f.write(new_content)
|
|
|
|
# Update __version__.py
|
|
with open(self.version_file, 'r') as f:
|
|
version_content = f.read()
|
|
|
|
new_version_content = re.sub(
|
|
r'__version__\s*=\s*"[^"]+"',
|
|
f'__version__ = "{new_version}"',
|
|
version_content
|
|
)
|
|
|
|
if not self.dry_run:
|
|
with open(self.version_file, 'w') as f:
|
|
f.write(new_version_content)
|
|
|
|
def get_git_status(self) -> Dict[str, any]:
|
|
"""Get current git repository status."""
|
|
try:
|
|
# Check if in git repo
|
|
result = self.run_command(['git', 'rev-parse', '--git-dir'], skip_dry_run=True)
|
|
|
|
# Get current branch
|
|
branch_result = self.run_command(['git', 'branch', '--show-current'], skip_dry_run=True)
|
|
current_branch = branch_result.stdout.strip()
|
|
|
|
# Check for uncommitted changes
|
|
status_result = self.run_command(['git', 'status', '--porcelain'], skip_dry_run=True)
|
|
has_changes = bool(status_result.stdout.strip())
|
|
|
|
# Get latest commit
|
|
commit_result = self.run_command(['git', 'rev-parse', '--short', 'HEAD'], skip_dry_run=True)
|
|
latest_commit = commit_result.stdout.strip()
|
|
|
|
# Get latest tag
|
|
try:
|
|
tag_result = self.run_command(['git', 'describe', '--tags', '--abbrev=0'], skip_dry_run=True)
|
|
latest_tag = tag_result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
latest_tag = None
|
|
|
|
return {
|
|
'is_repo': True,
|
|
'branch': current_branch,
|
|
'has_changes': has_changes,
|
|
'latest_commit': latest_commit,
|
|
'latest_tag': latest_tag
|
|
}
|
|
except subprocess.CalledProcessError:
|
|
return {'is_repo': False}
|
|
|
|
def generate_changelog_entry(self, version: str, since_tag: str = None) -> str:
|
|
"""Generate changelog entry from git commits."""
|
|
print(f"📋 Generating changelog for {version}")
|
|
|
|
# Get commits since last tag or all commits
|
|
if since_tag:
|
|
cmd = ['git', 'log', f'{since_tag}..HEAD', '--oneline', '--no-merges']
|
|
else:
|
|
cmd = ['git', 'log', '--oneline', '--no-merges']
|
|
|
|
try:
|
|
result = self.run_command(cmd)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
except subprocess.CalledProcessError:
|
|
commits = []
|
|
|
|
# Categorize commits
|
|
features = []
|
|
fixes = []
|
|
docs = []
|
|
other = []
|
|
|
|
for commit in commits:
|
|
if not commit:
|
|
continue
|
|
|
|
commit_msg = commit.split(' ', 1)[1] if ' ' in commit else commit
|
|
|
|
if commit_msg.startswith(('feat:', 'feature:')):
|
|
features.append(commit_msg)
|
|
elif commit_msg.startswith(('fix:', 'bugfix:')):
|
|
fixes.append(commit_msg)
|
|
elif commit_msg.startswith(('docs:', 'doc:')):
|
|
docs.append(commit_msg)
|
|
else:
|
|
other.append(commit_msg)
|
|
|
|
# Generate changelog entry
|
|
date = datetime.now().strftime('%Y-%m-%d')
|
|
entry = f"## [{version}] - {date}\n\n"
|
|
|
|
if features:
|
|
entry += "### Added\n"
|
|
for feat in features:
|
|
entry += f"- {feat}\n"
|
|
entry += "\n"
|
|
|
|
if fixes:
|
|
entry += "### Fixed\n"
|
|
for fix in fixes:
|
|
entry += f"- {fix}\n"
|
|
entry += "\n"
|
|
|
|
if docs:
|
|
entry += "### Documentation\n"
|
|
for doc in docs:
|
|
entry += f"- {doc}\n"
|
|
entry += "\n"
|
|
|
|
if other:
|
|
entry += "### Other\n"
|
|
for oth in other:
|
|
entry += f"- {oth}\n"
|
|
entry += "\n"
|
|
|
|
return entry
|
|
|
|
def update_changelog(self, version: str, since_tag: str = None):
|
|
"""Update CHANGELOG.md with new version entry."""
|
|
entry = self.generate_changelog_entry(version, since_tag)
|
|
|
|
# Read existing changelog or create new one
|
|
if self.changelog_file.exists():
|
|
with open(self.changelog_file, 'r') as f:
|
|
existing_content = f.read()
|
|
else:
|
|
existing_content = "# Changelog\n\nAll notable changes to MarkiTect will be documented in this file.\n\n"
|
|
|
|
# Insert new entry after header
|
|
lines = existing_content.split('\n')
|
|
header_end = 0
|
|
for i, line in enumerate(lines):
|
|
if line.startswith('## [') or (i > 0 and not line.startswith('#')):
|
|
header_end = i
|
|
break
|
|
|
|
new_lines = lines[:header_end] + entry.split('\n') + lines[header_end:]
|
|
new_content = '\n'.join(new_lines)
|
|
|
|
if not self.dry_run:
|
|
with open(self.changelog_file, 'w') as f:
|
|
f.write(new_content)
|
|
|
|
def validate_release_state(self) -> Tuple[bool, List[str]]:
|
|
"""Validate that the repository is ready for release."""
|
|
issues = []
|
|
|
|
git_status = self.get_git_status()
|
|
|
|
if not git_status['is_repo']:
|
|
issues.append("Not in a git repository")
|
|
else:
|
|
if git_status['has_changes'] and not self.force:
|
|
issues.append("Repository has uncommitted changes")
|
|
|
|
if git_status['branch'] != 'main' and not self.force:
|
|
issues.append(f"Not on main branch (currently on {git_status['branch']})")
|
|
|
|
# Check if tests pass (skip for dry run)
|
|
if not self.dry_run:
|
|
try:
|
|
print("🧪 Running tests...")
|
|
test_result = self.run_command(['make', 'test'], capture=False)
|
|
if test_result.returncode != 0:
|
|
issues.append("Tests are failing")
|
|
except subprocess.CalledProcessError:
|
|
issues.append("Could not run tests (make test failed)")
|
|
except FileNotFoundError:
|
|
# Try pytest directly
|
|
try:
|
|
test_result = self.run_command(['python', '-m', 'pytest'])
|
|
if test_result.returncode != 0:
|
|
issues.append("Tests are failing")
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
issues.append("Could not run tests")
|
|
else:
|
|
print("🧪 Skipping tests in dry run mode")
|
|
|
|
return len(issues) == 0, issues
|
|
|
|
def build_packages(self, version: str):
|
|
"""Build release packages."""
|
|
print(f"📦 Building packages for version {version}")
|
|
|
|
# Clean previous builds
|
|
build_dirs = ['build', 'dist', '*.egg-info']
|
|
for pattern in build_dirs:
|
|
self.run_command(['rm', '-rf'] + [str(self.project_root / pattern)])
|
|
|
|
# Build source distribution
|
|
print("Building source distribution...")
|
|
self.run_command(['python', '-m', 'build', '--sdist'], capture=False)
|
|
|
|
# Build wheel
|
|
print("Building wheel...")
|
|
self.run_command(['python', '-m', 'build', '--wheel'], capture=False)
|
|
|
|
print("✅ Packages built successfully")
|
|
|
|
def create_git_tag(self, version: str, message: str = None):
|
|
"""Create and push git tag."""
|
|
tag_name = f"v{version}"
|
|
tag_message = message or f"Release {version}"
|
|
|
|
print(f"🏷️ Creating git tag {tag_name}")
|
|
|
|
# Create annotated tag
|
|
self.run_command(['git', 'tag', '-a', tag_name, '-m', tag_message])
|
|
|
|
# Push tag
|
|
print(f"📤 Pushing tag to origin...")
|
|
self.run_command(['git', 'push', 'origin', tag_name])
|
|
|
|
def show_status(self):
|
|
"""Show current release status."""
|
|
print("🔍 MarkiTect Release Status")
|
|
print("=" * 50)
|
|
|
|
current_version = self.get_current_version()
|
|
print(f"Current Version: {current_version}")
|
|
|
|
git_status = self.get_git_status()
|
|
if git_status['is_repo']:
|
|
print(f"Git Branch: {git_status['branch']}")
|
|
print(f"Latest Commit: {git_status['latest_commit']}")
|
|
print(f"Latest Tag: {git_status['latest_tag'] or 'None'}")
|
|
print(f"Uncommitted Changes: {'Yes' if git_status['has_changes'] else 'No'}")
|
|
else:
|
|
print("Git Repository: Not available")
|
|
|
|
# Check build tools
|
|
print("\nBuild Tools:")
|
|
try:
|
|
self.run_command(['python', '-m', 'build', '--help'])
|
|
print("✅ build module available")
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
print("❌ build module not available (pip install build)")
|
|
|
|
# Check if packages exist
|
|
dist_dir = self.project_root / "dist"
|
|
if dist_dir.exists():
|
|
packages = list(dist_dir.glob("*"))
|
|
print(f"\nExisting Packages: {len(packages)} files in dist/")
|
|
for pkg in packages:
|
|
print(f" - {pkg.name}")
|
|
else:
|
|
print("\nExisting Packages: None")
|
|
|
|
def prepare_release(self, version: str, pre_release: bool = False):
|
|
"""Prepare a new release."""
|
|
print(f"🚀 Preparing release {version}")
|
|
|
|
# Validate version format
|
|
if not self.validate_version(version):
|
|
raise ValueError(f"Invalid version format: {version}")
|
|
|
|
# Check if version is newer than current
|
|
current_version = self.get_current_version()
|
|
if self.compare_versions(version, current_version) <= 0 and not self.force:
|
|
raise ValueError(f"New version {version} must be greater than current {current_version}")
|
|
|
|
# Validate release state
|
|
is_valid, issues = self.validate_release_state()
|
|
if not is_valid:
|
|
print("❌ Release validation failed:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
if not self.force:
|
|
sys.exit(1)
|
|
else:
|
|
print("⚠️ Continuing with --force flag")
|
|
|
|
# Update version
|
|
self.update_version(version)
|
|
|
|
# Update changelog
|
|
git_status = self.get_git_status()
|
|
since_tag = git_status.get('latest_tag') if git_status['is_repo'] else None
|
|
self.update_changelog(version, since_tag)
|
|
|
|
print(f"✅ Release {version} prepared successfully")
|
|
print("Next steps:")
|
|
print("1. Review and edit CHANGELOG.md if needed")
|
|
print("2. Commit changes: git add -A && git commit -m 'Prepare release {version}'")
|
|
print("3. Run: python release.py publish --version {version}")
|
|
|
|
def publish_release(self, version: str):
|
|
"""Publish a complete release."""
|
|
print(f"📢 Publishing release {version}")
|
|
|
|
# Validate state
|
|
is_valid, issues = self.validate_release_state()
|
|
if not is_valid and not self.force:
|
|
print("❌ Cannot publish release due to validation issues:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
sys.exit(1)
|
|
|
|
# Build packages
|
|
self.build_packages(version)
|
|
|
|
# Create git tag
|
|
self.create_git_tag(version)
|
|
|
|
print(f"✅ Release {version} published successfully!")
|
|
print(f"📦 Packages available in dist/")
|
|
print(f"🏷️ Git tag v{version} created and pushed")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="MarkiTect Release Management Tool",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__.split('\n\n')[1]
|
|
)
|
|
|
|
parser.add_argument('command', choices=['prepare', 'build', 'tag', 'publish', 'status', 'validate'],
|
|
help='Release command to execute')
|
|
parser.add_argument('--version', type=str, help='Target version (e.g., 1.0.0)')
|
|
parser.add_argument('--pre-release', action='store_true', help='Mark as pre-release')
|
|
parser.add_argument('--dry-run', action='store_true', help='Show what would be done')
|
|
parser.add_argument('--force', action='store_true', help='Force operation despite warnings')
|
|
|
|
args = parser.parse_args()
|
|
|
|
manager = ReleaseManager(dry_run=args.dry_run, force=args.force)
|
|
|
|
try:
|
|
if args.command == 'status':
|
|
manager.show_status()
|
|
|
|
elif args.command == 'validate':
|
|
is_valid, issues = manager.validate_release_state()
|
|
if is_valid:
|
|
print("✅ Repository is ready for release")
|
|
else:
|
|
print("❌ Release validation failed:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
sys.exit(1)
|
|
|
|
elif args.command == 'prepare':
|
|
if not args.version:
|
|
print("❌ --version is required for prepare command")
|
|
sys.exit(1)
|
|
manager.prepare_release(args.version, args.pre_release)
|
|
|
|
elif args.command == 'build':
|
|
version = args.version or manager.get_current_version()
|
|
manager.build_packages(version)
|
|
|
|
elif args.command == 'tag':
|
|
if not args.version:
|
|
print("❌ --version is required for tag command")
|
|
sys.exit(1)
|
|
manager.create_git_tag(args.version)
|
|
|
|
elif args.command == 'publish':
|
|
if not args.version:
|
|
print("❌ --version is required for publish command")
|
|
sys.exit(1)
|
|
manager.publish_release(args.version)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |