Finalize release script transition
- 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>
This commit is contained in:
398
release.py
Executable file → Normal file
398
release.py
Executable file → Normal file
@@ -1,146 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MarkiTect Release Management Tool
|
||||
MarkiTect Release Management Tool (setuptools-scm version)
|
||||
|
||||
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
|
||||
This simplified script works with setuptools-scm for automatic version management.
|
||||
Versions are automatically derived from git tags - no manual version bumping needed.
|
||||
|
||||
Usage:
|
||||
python release.py [command] [options]
|
||||
python release_simplified.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
|
||||
tag Create git tag for version (e.g., v0.8.0)
|
||||
build Build release packages
|
||||
publish Complete release workflow (tag + build + distribute)
|
||||
|
||||
Options:
|
||||
--version VERSION Target version (e.g., 1.0.0, 1.0.1-rc1)
|
||||
--pre-release Mark as pre-release
|
||||
--version VERSION Git tag version (e.g., 0.8.0, 1.0.0-rc1)
|
||||
--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
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import tempfile
|
||||
|
||||
|
||||
class ReleaseManager:
|
||||
"""Manages the MarkiTect release process."""
|
||||
class SimpleReleaseManager:
|
||||
"""Simplified release manager using setuptools-scm."""
|
||||
|
||||
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, cwd=self.project_root)
|
||||
|
||||
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_current_version_from_scm(self) -> str:
|
||||
"""Get current version using setuptools-scm."""
|
||||
try:
|
||||
result = self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
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()
|
||||
@@ -170,103 +84,9 @@ class ReleaseManager:
|
||||
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']:
|
||||
@@ -274,72 +94,58 @@ class ReleaseManager:
|
||||
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}"
|
||||
if not version.startswith('v'):
|
||||
tag_name = f"v{version}"
|
||||
else:
|
||||
tag_name = 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])
|
||||
print(f"✅ Tag {tag_name} created")
|
||||
|
||||
# Push tag
|
||||
print(f"📤 Pushing tag to origin...")
|
||||
self.run_command(['git', 'push', 'origin', tag_name])
|
||||
# Optionally push tag (can be done manually)
|
||||
try:
|
||||
print(f"📤 Pushing tag to origin...")
|
||||
self.run_command(['git', 'push', 'origin', tag_name])
|
||||
print(f"✅ Tag pushed to origin")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"⚠️ Could not push tag to origin: {e}")
|
||||
print("You can push it manually with: git push origin " + tag_name)
|
||||
|
||||
def build_packages(self):
|
||||
"""Build release packages using setuptools-scm."""
|
||||
print(f"📦 Building packages (version will be auto-determined by setuptools-scm)")
|
||||
|
||||
# Clean previous builds
|
||||
for pattern in ['build', 'dist', '*.egg-info']:
|
||||
try:
|
||||
self.run_command(['rm', '-rf', pattern])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Build source distribution and wheel
|
||||
print("Building packages...")
|
||||
self.run_command(['python', '-m', 'build'], capture=False)
|
||||
print("✅ Packages built successfully")
|
||||
|
||||
def show_status(self):
|
||||
"""Show current release status."""
|
||||
print("🔍 MarkiTect Release Status")
|
||||
print("=" * 50)
|
||||
print("🔍 MarkiTect Release Status (setuptools-scm)")
|
||||
print("=" * 60)
|
||||
|
||||
current_version = self.get_current_version()
|
||||
print(f"Current Version: {current_version}")
|
||||
# Get version from setuptools-scm
|
||||
scm_version = self.get_current_version_from_scm()
|
||||
print(f"Current Version (setuptools-scm): {scm_version}")
|
||||
|
||||
git_status = self.get_git_status()
|
||||
if git_status['is_repo']:
|
||||
@@ -353,99 +159,66 @@ class ReleaseManager:
|
||||
# Check build tools
|
||||
print("\nBuild Tools:")
|
||||
try:
|
||||
self.run_command(['python', '-m', 'build', '--help'])
|
||||
self.run_command(['python', '-m', 'build', '--help'], skip_dry_run=True)
|
||||
print("✅ build module available")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("❌ build module not available (pip install build)")
|
||||
|
||||
# Check if packages exist
|
||||
try:
|
||||
self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True)
|
||||
print("✅ setuptools-scm available")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("❌ setuptools-scm not available")
|
||||
|
||||
# Check existing packages
|
||||
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:
|
||||
for pkg in packages[-5:]: # Show last 5
|
||||
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}")
|
||||
"""Complete release workflow."""
|
||||
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:")
|
||||
print("❌ Cannot publish release:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
sys.exit(1)
|
||||
return False
|
||||
|
||||
# Build packages
|
||||
self.build_packages(version)
|
||||
|
||||
# Create git tag
|
||||
# Create git tag (this determines the version for setuptools-scm)
|
||||
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")
|
||||
# Build packages (setuptools-scm will use the tag for version)
|
||||
self.build_packages()
|
||||
|
||||
print(f"✅ Release {version} completed!")
|
||||
print("📦 Packages available in dist/")
|
||||
print(f"🏷️ Git tag v{version} created")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MarkiTect Release Management Tool",
|
||||
description="MarkiTect Release Management Tool (setuptools-scm)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split('\n\n')[1]
|
||||
)
|
||||
|
||||
parser.add_argument('command', choices=['prepare', 'build', 'tag', 'publish', 'status', 'validate'],
|
||||
parser.add_argument('command', choices=['status', 'validate', 'tag', 'build', 'publish'],
|
||||
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('--version', type=str, help='Target version for git tag (e.g., 0.8.0)')
|
||||
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)
|
||||
manager = SimpleReleaseManager(dry_run=args.dry_run, force=args.force)
|
||||
|
||||
try:
|
||||
if args.command == 'status':
|
||||
@@ -461,22 +234,15 @@ def main():
|
||||
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 == 'build':
|
||||
manager.build_packages()
|
||||
|
||||
elif args.command == 'publish':
|
||||
if not args.version:
|
||||
print("❌ --version is required for publish command")
|
||||
|
||||
492
release_old_manual.py
Executable file
492
release_old_manual.py
Executable file
@@ -0,0 +1,492 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user