#!/usr/bin/env python3 """ MarkiTect Release Management Tool (setuptools-scm version) 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_simplified.py [command] [options] Commands: 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 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 """ import subprocess import argparse import sys from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Tuple 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() 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) 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: # 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 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']})") return len(issues) == 0, issues def create_git_tag(self, version: str, message: str = None): """Create and push git tag.""" 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") # 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 (setuptools-scm)") print("=" * 60) # 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']: 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'], skip_dry_run=True) print("✅ build module available") except (subprocess.CalledProcessError, FileNotFoundError): print("❌ build module not available (pip install build)") 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[-5:]: # Show last 5 print(f" - {pkg.name}") else: print("\nExisting Packages: None") def publish_release(self, version: str): """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:") for issue in issues: print(f" - {issue}") return False # Create git tag (this determines the version for setuptools-scm) self.create_git_tag(version) # 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 (setuptools-scm)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split('\n\n')[1] ) parser.add_argument('command', choices=['status', 'validate', 'tag', 'build', 'publish'], help='Release command to execute') 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 = SimpleReleaseManager(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 == '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") sys.exit(1) manager.publish_release(args.version) except Exception as e: print(f"❌ Error: {e}") sys.exit(1) if __name__ == "__main__": main()