✨ Features: - GiteaPackageRegistry client for PyPI-compatible uploads - Enhanced release.py with upload/registry commands - New Makefile targets for Gitea publishing workflow - Comprehensive documentation with examples 📦 New Commands: - `release.py registry` - Show registry info & authentication - `release.py upload` - Upload packages to Gitea - `release.py publish --to-gitea` - Complete release + upload - `make release-publish-gitea VERSION=x.y.z` - One-command release 🔧 Infrastructure: - Automatic package detection (wheel + sdist) - Dry-run support for safe testing - Error handling and detailed feedback - Authentication validation 📚 Documentation: - PACKAGE_PUBLISHING.md with complete setup guide - Usage examples and troubleshooting 🚀 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
#!/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.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)
|
||
upload Upload packages to Gitea registry
|
||
registry Show Gitea package registry information
|
||
|
||
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
|
||
--to-gitea Upload to Gitea package registry
|
||
"""
|
||
|
||
import subprocess
|
||
import argparse
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
try:
|
||
from gitea.package_registry import GiteaPackageRegistry
|
||
GITEA_AVAILABLE = True
|
||
except ImportError:
|
||
GITEA_AVAILABLE = False
|
||
|
||
|
||
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 upload_to_gitea(self, dry_run: bool = False) -> bool:
|
||
"""Upload packages to Gitea package registry."""
|
||
if not GITEA_AVAILABLE:
|
||
print("❌ Gitea package registry not available (missing gitea module)")
|
||
return False
|
||
|
||
try:
|
||
registry = GiteaPackageRegistry()
|
||
print(f"📡 Uploading to Gitea registry: {registry.pypi_registry_url}")
|
||
|
||
# Find built packages
|
||
dist_dir = self.project_root / "dist"
|
||
if not dist_dir.exists():
|
||
print("❌ No dist/ directory found. Run 'build' command first.")
|
||
return False
|
||
|
||
wheel_files = list(dist_dir.glob("*.whl"))
|
||
sdist_files = list(dist_dir.glob("*.tar.gz"))
|
||
|
||
if not wheel_files and not sdist_files:
|
||
print("❌ No package files found in dist/")
|
||
return False
|
||
|
||
# Upload each package
|
||
success = True
|
||
for wheel_file in wheel_files:
|
||
# Find matching sdist
|
||
sdist_file = None
|
||
for sdist in sdist_files:
|
||
if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]:
|
||
sdist_file = sdist
|
||
break
|
||
|
||
if not registry.upload_package(wheel_file, sdist_file, dry_run=dry_run):
|
||
success = False
|
||
|
||
# Upload any remaining sdists
|
||
uploaded_sdists = []
|
||
for wheel_file in wheel_files:
|
||
for sdist in sdist_files:
|
||
if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]:
|
||
uploaded_sdists.append(sdist)
|
||
|
||
for sdist_file in sdist_files:
|
||
if sdist_file not in uploaded_sdists:
|
||
if not registry.upload_package(sdist_file, dry_run=dry_run):
|
||
success = False
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
print(f"❌ Upload to Gitea failed: {e}")
|
||
return False
|
||
|
||
def show_gitea_registry_info(self):
|
||
"""Show Gitea package registry information."""
|
||
if not GITEA_AVAILABLE:
|
||
print("❌ Gitea package registry not available (missing gitea module)")
|
||
return
|
||
|
||
try:
|
||
registry = GiteaPackageRegistry()
|
||
info = registry.get_registry_info()
|
||
|
||
print("📦 Gitea Package Registry Information")
|
||
print("=" * 50)
|
||
print(f"Gitea URL: {info['gitea_url']}")
|
||
print(f"Repository: {info['repo_owner']}/{info['repo_name']}")
|
||
print(f"PyPI Registry URL: {info['pypi_registry_url']}")
|
||
print(f"Package List URL: {info['package_list_url']}")
|
||
print(f"Authentication Configured: {'✅' if info['auth_configured'] else '❌'}")
|
||
print(f"Authentication Valid: {'✅' if info['auth_valid'] else '❌' if info['auth_configured'] else 'N/A'}")
|
||
|
||
if info['auth_configured']:
|
||
try:
|
||
packages = registry.list_packages()
|
||
print(f"\nExisting Packages: {len(packages)}")
|
||
for package in packages[:5]: # Show first 5
|
||
print(f" - {package.get('name', 'unknown')} (type: {package.get('type', 'unknown')})")
|
||
except Exception as e:
|
||
print(f"\nError listing packages: {e}")
|
||
else:
|
||
print("\nℹ️ Set GITEA_API_TOKEN environment variable for package management")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error getting registry info: {e}")
|
||
|
||
def publish_with_gitea(self, version: str, dry_run: bool = False) -> bool:
|
||
"""Complete release workflow including Gitea upload."""
|
||
if not self.publish_release(version):
|
||
return False
|
||
|
||
if not self.upload_to_gitea(dry_run=dry_run):
|
||
print("⚠️ Release completed but Gitea upload failed")
|
||
return False
|
||
|
||
print("🎉 Complete release with Gitea upload successful!")
|
||
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', 'upload', 'registry'],
|
||
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')
|
||
parser.add_argument('--to-gitea', action='store_true', help='Include Gitea package registry upload')
|
||
|
||
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)
|
||
|
||
if args.to_gitea:
|
||
manager.publish_with_gitea(args.version, args.dry_run)
|
||
else:
|
||
manager.publish_release(args.version)
|
||
|
||
elif args.command == 'upload':
|
||
manager.upload_to_gitea(args.dry_run)
|
||
|
||
elif args.command == 'registry':
|
||
manager.show_gitea_registry_info()
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error: {e}")
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |