feat: implement modular capability system with automatic discovery

- Move release management to capabilities/release-management/ with complete Makefile
- Create automatic capability discovery system in scripts/capability_discovery.mk
- Add capability-manager subagent for managing modular architecture
- Implement target delegation system enabling capability-name-target patterns
- Create Makefiles for markitect-content, markitect-utils, and issue-facade capabilities
- Remove legacy release management code and documentation from main project
- Update main Makefile to use capability discovery and delegation
- Add comprehensive capability status, help, and management targets

The capability system provides:
- Automatic discovery of capabilities with Makefiles
- Clean target delegation without conflicts
- Modular architecture following established patterns
- Comprehensive help and status reporting
- Zero-conflict capability integration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:29:15 +01:00
parent d505c15d40
commit d0ffdc057c
38 changed files with 3978 additions and 1361 deletions

View File

@@ -0,0 +1,14 @@
"""
Core release management classes.
This module provides the main classes for orchestrating releases:
- ReleaseManager: Main coordinator for release workflows
- PackageBuilder: Package building and setuptools-scm integration
- PublishManager: Package publication to registries
"""
from .manager import ReleaseManager
from .builder import PackageBuilder
from .publisher import PublishManager
__all__ = ["ReleaseManager", "PackageBuilder", "PublishManager"]

View File

@@ -0,0 +1,166 @@
"""
Package building functionality for releases.
This module handles package building using setuptools-scm and the Python build module.
"""
import subprocess
import sys
from pathlib import Path
from typing import List, Optional, Dict, Any
from ..utils.version import VersionManager
class PackageBuilder:
"""Handles package building with setuptools-scm integration."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""Initialize the package builder.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
self.dist_dir = self.project_root / "dist"
def get_current_version(self) -> str:
"""Get current version using setuptools-scm.
Returns:
Current version string or "unknown" if unavailable.
"""
try:
result = self._run_command(
['python', '-m', 'setuptools_scm'],
capture=True,
skip_dry_run=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return "unknown"
def clean_build(self) -> None:
"""Clean previous build artifacts."""
print("🧹 Cleaning build artifacts...")
patterns = ['build', 'dist', '*.egg-info']
for pattern in patterns:
try:
if pattern == 'dist' and self.dist_dir.exists():
if self.dry_run:
print(f"[DRY RUN] Would remove: {self.dist_dir}")
else:
import shutil
shutil.rmtree(self.dist_dir)
print(f"✅ Removed: {self.dist_dir}")
elif pattern != 'dist':
self._run_command(['rm', '-rf', pattern])
except subprocess.CalledProcessError:
pass # Ignore if files don't exist
def build_packages(self) -> bool:
"""Build release packages using setuptools-scm.
Returns:
True if build successful, False otherwise.
"""
print(f"📦 Building packages (version auto-determined by setuptools-scm)")
# Clean previous builds
self.clean_build()
# Build packages
try:
print("Building packages...")
self._run_command(['python', '-m', 'build'], capture=False)
print("✅ Packages built successfully")
# Show package details
self._show_package_details()
return True
except subprocess.CalledProcessError as e:
print(f"❌ Package build failed: {e}")
return False
def get_built_packages(self) -> Dict[str, List[Path]]:
"""Get list of built packages by type.
Returns:
Dictionary with 'wheels' and 'sdists' keys containing file paths.
"""
if not self.dist_dir.exists():
return {"wheels": [], "sdists": []}
wheels = list(self.dist_dir.glob("*.whl"))
sdists = list(self.dist_dir.glob("*.tar.gz"))
return {"wheels": wheels, "sdists": sdists}
def validate_packages(self) -> bool:
"""Validate that expected packages were built.
Returns:
True if packages are valid, False otherwise.
"""
packages = self.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/")
return False
# Check package sizes
for wheel in packages["wheels"]:
if wheel.stat().st_size < 1000: # Less than 1KB
print(f"⚠️ Warning: {wheel.name} is very small ({wheel.stat().st_size} bytes)")
for sdist in packages["sdists"]:
if sdist.stat().st_size < 1000: # Less than 1KB
print(f"⚠️ Warning: {sdist.name} is very small ({sdist.stat().st_size} bytes)")
return True
def _show_package_details(self) -> None:
"""Show details about built packages."""
packages = self.get_built_packages()
if packages["wheels"] or packages["sdists"]:
print(f"\n📦 Built packages in {self.dist_dir}:")
for wheel in packages["wheels"]:
size = wheel.stat().st_size
print(f" 🎯 {wheel.name} ({size:,} bytes)")
for sdist in packages["sdists"]:
size = sdist.stat().st_size
print(f" 📄 {sdist.name} ({size:,} bytes)")
else:
print("❌ No packages found")
def _run_command(self, cmd: List[str], capture: bool = True,
check: bool = True, skip_dry_run: bool = False) -> subprocess.CompletedProcess:
"""Run a command with optional dry-run support.
Args:
cmd: Command to execute
capture: Whether to capture output
check: Whether to raise on non-zero exit
skip_dry_run: Whether to skip dry-run check and always execute
Returns:
CompletedProcess result
"""
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
)

View File

@@ -0,0 +1,215 @@
"""
Main release manager orchestrating complete release workflows.
This module provides the primary ReleaseManager class that coordinates
all aspects of the release process.
"""
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any
from .builder import PackageBuilder
from .publisher import PublishManager
from ..git.manager import GitManager
from ..utils.validation import ReleaseValidator
class ReleaseManager:
"""Main orchestrator for release workflows."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False, force: bool = False):
"""Initialize the release manager.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
force: If True, skip validation checks.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
self.force = force
# Initialize component managers
self.git_manager = GitManager(project_root, dry_run)
self.builder = PackageBuilder(project_root, dry_run)
self.publisher = PublishManager(project_root, dry_run)
self.validator = ReleaseValidator(project_root)
def get_release_status(self) -> Dict[str, Any]:
"""Get comprehensive release status information.
Returns:
Dictionary with release status details
"""
status = {}
# Version information
status['version'] = self.builder.get_current_version()
# Git status
git_status = self.git_manager.get_repository_status()
status.update(git_status)
# Package status
packages = self.builder.get_built_packages()
status['packages'] = {
'wheels': [p.name for p in packages['wheels']],
'sdists': [p.name for p in packages['sdists']],
'total_count': len(packages['wheels']) + len(packages['sdists'])
}
# Validation status
is_valid, issues = self.validate_release_state()
status['validation'] = {
'is_valid': is_valid,
'issues': issues
}
return status
def validate_release_state(self) -> Tuple[bool, List[str]]:
"""Validate that the repository is ready for release.
Returns:
Tuple of (is_valid, list_of_issues)
"""
return self.validator.validate_release_state(force=self.force)
def create_tag(self, version: str, message: Optional[str] = None) -> bool:
"""Create a git tag for the release.
Args:
version: Version to tag (e.g., "1.0.0")
message: Optional tag message
Returns:
True if tag created successfully, False otherwise
"""
# Validate release state first
is_valid, issues = self.validate_release_state()
if not is_valid and not self.force:
print("❌ Cannot create tag:")
for issue in issues:
print(f" - {issue}")
return False
return self.git_manager.create_tag(version, message)
def build_packages(self) -> bool:
"""Build release packages.
Returns:
True if build successful, False otherwise
"""
success = self.builder.build_packages()
if success:
success = self.builder.validate_packages()
return success
def publish_release(self, version: str, registry_type: str = 'gitea',
skip_build: bool = False) -> bool:
"""Complete release workflow: validate, tag, build, and publish.
Args:
version: Version to release
registry_type: Type of registry to publish to
skip_build: If True, skip building and use existing packages
Returns:
True if release successful, False otherwise
"""
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)
if not self.git_manager.tag_exists(f"v{version}"):
if not self.create_tag(version):
return False
else:
print(f"✅ Tag v{version} already exists")
# Build packages (setuptools-scm will use the tag for version)
if not skip_build:
if not self.build_packages():
return False
else:
print("⏭️ Skipping build (using existing packages)")
# Publish packages
if not self.publisher.publish_packages(registry_type):
print("⚠️ Release completed but publishing failed")
return False
print(f"✅ Release {version} completed successfully!")
print(f"📦 Packages published to {registry_type}")
print(f"🏷️ Git tag v{version} created")
return True
def publish_with_fallback(self, version: str, registry_type: str = 'gitea',
skip_build: bool = False) -> bool:
"""Complete release workflow with fallback to release assets.
Args:
version: Version to release
registry_type: Type of registry to publish to
skip_build: If True, skip building and use existing packages
Returns:
True if release successful, False otherwise
"""
# Try normal publish first
if self.publish_release(version, registry_type, skip_build):
return True
# If that fails, try release assets fallback
print("🔄 Attempting release assets fallback...")
return self.publisher.publish_as_release_assets(version, registry_type)
def upload_existing_packages(self, registry_type: str = 'gitea') -> bool:
"""Upload existing packages without building or tagging.
Args:
registry_type: Type of registry to upload to
Returns:
True if upload successful, False otherwise
"""
print(f"📤 Uploading existing packages to {registry_type}")
packages = self.builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
all_packages = packages["wheels"] + packages["sdists"]
return self.publisher.upload_specific_packages(all_packages, registry_type)
def clean_build_artifacts(self) -> None:
"""Clean build artifacts and temporary files."""
self.builder.clean_build()
def show_registry_info(self, registry_type: str = 'gitea') -> Dict[str, Any]:
"""Show information about a registry.
Args:
registry_type: Type of registry
Returns:
Dictionary with registry information
"""
return self.publisher.get_registry_info(registry_type)
def get_commits_since_last_tag(self) -> List[str]:
"""Get commits since the last release tag.
Returns:
List of commit messages since last tag
"""
return self.git_manager.get_commits_since_tag()

View File

@@ -0,0 +1,248 @@
"""
Package publishing functionality for releases.
This module handles publishing packages to various registries.
"""
from pathlib import Path
from typing import Dict, List, Optional, Any
from ..registries.factory import RegistryFactory
from ..registries.base import RegistryInterface
from .builder import PackageBuilder
class PublishManager:
"""Handles package publication to registries."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""Initialize the publish manager.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
def publish_packages(self, registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Publish packages to specified registry.
Args:
registry_type: Type of registry to publish to
registry_config: Optional registry configuration
Returns:
True if publishing successful, False otherwise
"""
try:
# Get registry client
registry = self._get_registry(registry_type, registry_config)
# Get built packages
builder = PackageBuilder(self.project_root)
packages = builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
# Upload packages
success = True
for wheel in packages["wheels"]:
if not self._upload_package_with_fallback(registry, wheel):
success = False
for sdist in packages["sdists"]:
if not self._upload_package_with_fallback(registry, sdist):
success = False
return success
except Exception as e:
print(f"❌ Publishing failed: {e}")
return False
def upload_specific_packages(self, package_paths: List[Path],
registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Upload specific package files to registry.
Args:
package_paths: List of paths to package files
registry_type: Type of registry to publish to
registry_config: Optional registry configuration
Returns:
True if all uploads successful, False otherwise
"""
try:
registry = self._get_registry(registry_type, registry_config)
success = True
for package_path in package_paths:
if not package_path.exists():
print(f"❌ Package not found: {package_path}")
success = False
continue
if not self._upload_package_with_fallback(registry, package_path):
success = False
return success
except Exception as e:
print(f"❌ Upload failed: {e}")
return False
def publish_as_release_assets(self, version: str,
registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Publish packages as release assets (fallback method).
Args:
version: Version to publish as
registry_type: Type of registry (must support release assets)
registry_config: Optional registry configuration
Returns:
True if publishing successful, False otherwise
"""
try:
registry = self._get_registry(registry_type, registry_config)
# Check if registry supports release assets
if not hasattr(registry, 'upload_package_as_release_assets'):
print(f"❌ Registry type '{registry_type}' does not support release assets")
return False
# Get built packages
builder = PackageBuilder(self.project_root)
packages = builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
# Find wheel and corresponding source distribution
success = True
for wheel in packages["wheels"]:
# Find matching sdist
sdist = None
wheel_name_parts = wheel.stem.split('-')
package_name = wheel_name_parts[0] if wheel_name_parts else ""
for potential_sdist in packages["sdists"]:
if potential_sdist.stem.startswith(package_name):
sdist = potential_sdist
break
# Upload as release assets
if not registry.upload_package_as_release_assets(
version, wheel, sdist, dry_run=self.dry_run
):
success = False
return success
except Exception as e:
print(f"❌ Release asset publishing failed: {e}")
return False
def get_registry_info(self, registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Get information about a registry.
Args:
registry_type: Type of registry
registry_config: Optional registry configuration
Returns:
Dictionary with registry information
"""
try:
registry = self._get_registry(registry_type, registry_config)
return registry.get_registry_info()
except Exception as e:
return {"error": str(e)}
def _get_registry(self, registry_type: str,
registry_config: Optional[Dict[str, Any]] = None) -> RegistryInterface:
"""Get a registry client.
Args:
registry_type: Type of registry
registry_config: Optional registry configuration
Returns:
Registry client instance
"""
if registry_config:
return RegistryFactory.create(registry_type, registry_config)
else:
# Try to load from pyproject.toml first
try:
return RegistryFactory.create_from_pyproject_config(
self.project_root / "pyproject.toml",
registry_type
)
except (ValueError, FileNotFoundError):
# Fallback to auto-detection
return RegistryFactory.create(registry_type)
def _upload_package_with_fallback(self, registry: RegistryInterface,
package_path: Path) -> bool:
"""Upload a package with fallback to release assets if needed.
Args:
registry: Registry client
package_path: Path to package file
Returns:
True if upload successful, False otherwise
"""
try:
# Try normal package upload first
return registry.upload_package(package_path, dry_run=self.dry_run)
except Exception as e:
print(f"⚠️ Package upload failed: {e}")
# Check if registry supports release assets as fallback
if hasattr(registry, 'upload_package_as_release_assets'):
print("🔄 Trying release assets as fallback...")
# Extract version from package filename for release assets
version = self._extract_version_from_filename(package_path)
if version:
return registry.upload_package_as_release_assets(
version, package_path, dry_run=self.dry_run
)
return False
def _extract_version_from_filename(self, package_path: Path) -> Optional[str]:
"""Extract version from package filename.
Args:
package_path: Path to package file
Returns:
Version string or None if not found
"""
try:
if package_path.suffix == '.whl':
# Wheel format: package-version-python-abi-platform.whl
parts = package_path.stem.split('-')
if len(parts) >= 2:
return parts[1]
elif package_path.suffix == '.gz' and package_path.name.endswith('.tar.gz'):
# Source dist format: package-version.tar.gz
name_without_tar = package_path.name.replace('.tar.gz', '')
parts = name_without_tar.split('-')
if len(parts) >= 2:
return parts[1]
except Exception:
pass
return None