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:
2025-11-08 20:23:53 +01:00
parent 8249296a43
commit 3298b0d911
2 changed files with 574 additions and 316 deletions

398
release.py Executable file → Normal file
View File

@@ -1,146 +1,60 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
MarkiTect Release Management Tool MarkiTect Release Management Tool (setuptools-scm version)
This script automates the release process for MarkiTect, including: This simplified script works with setuptools-scm for automatic version management.
- Version management and validation Versions are automatically derived from git tags - no manual version bumping needed.
- Changelog generation
- Git tagging and repository management
- Package building and distribution
- Release artifact creation
Usage: Usage:
python release.py [command] [options] python release_simplified.py [command] [options]
Commands: 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 status Show current release status
validate Validate current state for release 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: Options:
--version VERSION Target version (e.g., 1.0.0, 1.0.1-rc1) --version VERSION Git tag version (e.g., 0.8.0, 1.0.0-rc1)
--pre-release Mark as pre-release
--dry-run Show what would be done without making changes --dry-run Show what would be done without making changes
--force Force operation even with warnings --force Force operation even with warnings
--help Show help message
""" """
import os
import re
import sys
import json
import subprocess import subprocess
import argparse import argparse
import sys
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import tempfile
class ReleaseManager: class SimpleReleaseManager:
"""Manages the MarkiTect release process.""" """Simplified release manager using setuptools-scm."""
def __init__(self, dry_run=False, force=False): def __init__(self, dry_run=False, force=False):
self.dry_run = dry_run self.dry_run = dry_run
self.force = force self.force = force
self.project_root = Path(__file__).parent.absolute() 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: 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.""" """Run a command with optional dry-run support."""
if self.dry_run and not skip_dry_run: if self.dry_run and not skip_dry_run:
print(f"[DRY RUN] Would run: {' '.join(cmd)}") print(f"[DRY RUN] Would run: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 0, "", "") 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_from_scm(self) -> str:
"""Get current version using setuptools-scm."""
def get_current_version(self) -> str: try:
"""Get current version from pyproject.toml.""" result = self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True)
with open(self.pyproject_toml, 'r') as f: return result.stdout.strip()
content = f.read() except subprocess.CalledProcessError:
return "unknown"
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]: def get_git_status(self) -> Dict[str, any]:
"""Get current git repository status.""" """Get current git repository status."""
try: try:
# Check if in git repo
result = self.run_command(['git', 'rev-parse', '--git-dir'], skip_dry_run=True)
# Get current branch # Get current branch
branch_result = self.run_command(['git', 'branch', '--show-current'], skip_dry_run=True) branch_result = self.run_command(['git', 'branch', '--show-current'], skip_dry_run=True)
current_branch = branch_result.stdout.strip() current_branch = branch_result.stdout.strip()
@@ -170,103 +84,9 @@ class ReleaseManager:
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return {'is_repo': False} 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]]: def validate_release_state(self) -> Tuple[bool, List[str]]:
"""Validate that the repository is ready for release.""" """Validate that the repository is ready for release."""
issues = [] issues = []
git_status = self.get_git_status() git_status = self.get_git_status()
if not git_status['is_repo']: if not git_status['is_repo']:
@@ -274,72 +94,58 @@ class ReleaseManager:
else: else:
if git_status['has_changes'] and not self.force: if git_status['has_changes'] and not self.force:
issues.append("Repository has uncommitted changes") issues.append("Repository has uncommitted changes")
if git_status['branch'] != 'main' and not self.force: if git_status['branch'] != 'main' and not self.force:
issues.append(f"Not on main branch (currently on {git_status['branch']})") 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 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): def create_git_tag(self, version: str, message: str = None):
"""Create and push git tag.""" """Create and push git tag."""
tag_name = f"v{version}" if not version.startswith('v'):
tag_message = message or f"Release {version}" tag_name = f"v{version}"
else:
tag_name = version
tag_message = message or f"Release {version}"
print(f"🏷️ Creating git tag {tag_name}") print(f"🏷️ Creating git tag {tag_name}")
# Create annotated tag # Create annotated tag
self.run_command(['git', 'tag', '-a', tag_name, '-m', tag_message]) self.run_command(['git', 'tag', '-a', tag_name, '-m', tag_message])
print(f"✅ Tag {tag_name} created")
# Push tag # Optionally push tag (can be done manually)
print(f"📤 Pushing tag to origin...") try:
self.run_command(['git', 'push', 'origin', tag_name]) 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): def show_status(self):
"""Show current release status.""" """Show current release status."""
print("🔍 MarkiTect Release Status") print("🔍 MarkiTect Release Status (setuptools-scm)")
print("=" * 50) print("=" * 60)
current_version = self.get_current_version() # Get version from setuptools-scm
print(f"Current Version: {current_version}") scm_version = self.get_current_version_from_scm()
print(f"Current Version (setuptools-scm): {scm_version}")
git_status = self.get_git_status() git_status = self.get_git_status()
if git_status['is_repo']: if git_status['is_repo']:
@@ -353,99 +159,66 @@ class ReleaseManager:
# Check build tools # Check build tools
print("\nBuild Tools:") print("\nBuild Tools:")
try: try:
self.run_command(['python', '-m', 'build', '--help']) self.run_command(['python', '-m', 'build', '--help'], skip_dry_run=True)
print("✅ build module available") print("✅ build module available")
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
print("❌ build module not available (pip install build)") 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" dist_dir = self.project_root / "dist"
if dist_dir.exists(): if dist_dir.exists():
packages = list(dist_dir.glob("*")) packages = list(dist_dir.glob("*"))
print(f"\nExisting Packages: {len(packages)} files in dist/") 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}") print(f" - {pkg.name}")
else: else:
print("\nExisting Packages: None") 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): def publish_release(self, version: str):
"""Publish a complete release.""" """Complete release workflow."""
print(f"📢 Publishing release {version}") print(f"🚀 Publishing release {version}")
# Validate state # Validate state
is_valid, issues = self.validate_release_state() is_valid, issues = self.validate_release_state()
if not is_valid and not self.force: if not is_valid and not self.force:
print("❌ Cannot publish release due to validation issues:") print("❌ Cannot publish release:")
for issue in issues: for issue in issues:
print(f" - {issue}") print(f" - {issue}")
sys.exit(1) return False
# Build packages # Create git tag (this determines the version for setuptools-scm)
self.build_packages(version)
# Create git tag
self.create_git_tag(version) self.create_git_tag(version)
print(f"✅ Release {version} published successfully!") # Build packages (setuptools-scm will use the tag for version)
print(f"📦 Packages available in dist/") self.build_packages()
print(f"🏷️ Git tag v{version} created and pushed")
print(f"✅ Release {version} completed!")
print("📦 Packages available in dist/")
print(f"🏷️ Git tag v{version} created")
return True
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="MarkiTect Release Management Tool", description="MarkiTect Release Management Tool (setuptools-scm)",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split('\n\n')[1] 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') help='Release command to execute')
parser.add_argument('--version', type=str, help='Target version (e.g., 1.0.0)') parser.add_argument('--version', type=str, help='Target version for git tag (e.g., 0.8.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('--dry-run', action='store_true', help='Show what would be done')
parser.add_argument('--force', action='store_true', help='Force operation despite warnings') parser.add_argument('--force', action='store_true', help='Force operation despite warnings')
args = parser.parse_args() args = parser.parse_args()
manager = SimpleReleaseManager(dry_run=args.dry_run, force=args.force)
manager = ReleaseManager(dry_run=args.dry_run, force=args.force)
try: try:
if args.command == 'status': if args.command == 'status':
@@ -461,22 +234,15 @@ def main():
print(f" - {issue}") print(f" - {issue}")
sys.exit(1) 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': elif args.command == 'tag':
if not args.version: if not args.version:
print("❌ --version is required for tag command") print("❌ --version is required for tag command")
sys.exit(1) sys.exit(1)
manager.create_git_tag(args.version) manager.create_git_tag(args.version)
elif args.command == 'build':
manager.build_packages()
elif args.command == 'publish': elif args.command == 'publish':
if not args.version: if not args.version:
print("❌ --version is required for publish command") print("❌ --version is required for publish command")

492
release_old_manual.py Executable file
View 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()