From 3298b0d911105b5b5709023acaff2b1e9ebcf9d1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 8 Nov 2025 20:23:53 +0100 Subject: [PATCH] Finalize release script transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- release.py | 398 +++++++--------------------------- release_old_manual.py | 492 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 574 insertions(+), 316 deletions(-) mode change 100755 => 100644 release.py create mode 100755 release_old_manual.py diff --git a/release.py b/release.py old mode 100755 new mode 100644 index 6c715937..1cb4bcf3 --- a/release.py +++ b/release.py @@ -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") diff --git a/release_old_manual.py b/release_old_manual.py new file mode 100755 index 00000000..6c715937 --- /dev/null +++ b/release_old_manual.py @@ -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() \ No newline at end of file