Files
markitect-main/capabilities/release-management/src/release_management/utils/validation.py
tegwick 599de22f59 feat: implement optimization #3 - CHANGELOG validation in release flow
Add comprehensive CHANGELOG validation to release validation process:

- Add _validate_changelog() method that validates CHANGELOG.md against
  changelog-schema-v1.0.md using markitect validate --semantic
- Add validate_changelog_version() to check version section exists with
  proper date format and Unreleased section
- Add check_version_tag_consistency() to verify CHANGELOG versions
  match git tags
- Integrate CHANGELOG validation into validate_release_state()
- Add CHANGELOG-specific recommendations to _get_recommendations()

This prevents releases with invalid or inconsistent CHANGELOG files,
catching format errors before they become problems.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:11:40 +01:00

350 lines
12 KiB
Python

"""
Release validation utilities.
This module provides validation functions for release readiness.
"""
import subprocess
from pathlib import Path
from typing import List, Tuple, Optional
from ..git.manager import GitManager
class ReleaseValidator:
"""Validates release readiness and requirements."""
def __init__(self, project_root: Optional[Path] = None):
"""Initialize release validator.
Args:
project_root: Root directory of the project
"""
self.project_root = project_root or Path.cwd()
self.git_manager = GitManager(project_root)
def validate_release_state(self, force: bool = False) -> Tuple[bool, List[str]]:
"""Validate that repository is ready for release.
Args:
force: Skip validation checks if True
Returns:
Tuple of (is_valid, list_of_issues)
"""
if force:
return True, []
issues = []
# Git repository validation
git_issues = self._validate_git_state()
issues.extend(git_issues)
# Project structure validation
structure_issues = self._validate_project_structure()
issues.extend(structure_issues)
# Configuration validation
config_issues = self._validate_configuration()
issues.extend(config_issues)
# CHANGELOG validation
changelog_issues = self._validate_changelog()
issues.extend(changelog_issues)
return len(issues) == 0, issues
def _validate_git_state(self) -> List[str]:
"""Validate git repository state.
Returns:
List of git-related issues
"""
issues = []
status = self.git_manager.get_repository_status()
if not status['is_repo']:
issues.append("Not in a git repository")
return issues
if status['has_changes']:
issues.append("Repository has uncommitted changes")
if status['branch'] != 'main':
issues.append(f"Not on main branch (currently on {status['branch']})")
# Check if remote exists
remote_url = self.git_manager.get_remote_url()
if not remote_url:
issues.append("No git remote 'origin' configured")
return issues
def _validate_project_structure(self) -> List[str]:
"""Validate project structure for releases.
Returns:
List of project structure issues
"""
issues = []
# Check for required files
required_files = ['pyproject.toml']
for file_name in required_files:
file_path = self.project_root / file_name
if not file_path.exists():
issues.append(f"Missing required file: {file_name}")
# Check for setuptools-scm configuration
pyproject_path = self.project_root / 'pyproject.toml'
if pyproject_path.exists():
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
issues.append("Cannot read pyproject.toml (tomllib/tomli not available)")
return issues
try:
with open(pyproject_path, 'rb') as f:
config = tomllib.load(f)
# Check for setuptools-scm configuration
build_system = config.get('build-system', {})
if 'setuptools-scm' not in str(build_system.get('requires', [])):
issues.append("setuptools-scm not found in build-system.requires")
# Check for dynamic version
project_config = config.get('project', {})
if 'version' in project_config:
issues.append("Static version found in project config. Use dynamic versioning with setuptools-scm.")
dynamic = project_config.get('dynamic', [])
if 'version' not in dynamic:
issues.append("'version' not in project.dynamic. Add it for setuptools-scm.")
except Exception as e:
issues.append(f"Error reading pyproject.toml: {e}")
return issues
def _validate_configuration(self) -> List[str]:
"""Validate release configuration.
Returns:
List of configuration issues
"""
issues = []
# Check for environment variables that might be needed
import os
# Check for common auth tokens (warn, don't fail)
auth_vars = ['GITEA_API_TOKEN', 'PYPI_TOKEN', 'GITHUB_TOKEN']
available_auth = [var for var in auth_vars if os.getenv(var)]
if not available_auth:
issues.append("No authentication tokens found in environment. "
"Consider setting GITEA_API_TOKEN, PYPI_TOKEN, or GITHUB_TOKEN "
"for package publishing.")
return issues
def validate_version_string(self, version_string: str) -> Tuple[bool, List[str]]:
"""Validate a version string for release.
Args:
version_string: Version string to validate
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
if not version_string:
issues.append("Version string cannot be empty")
return False, issues
# Check basic format
if not version_string.replace('.', '').replace('-', '').replace('+', '').replace('a', '').replace('b', '').replace('rc', '').isalnum():
issues.append("Version string contains invalid characters")
# Check for development markers in release
dev_markers = ['dev', '.dev', '+dev']
if any(marker in version_string.lower() for marker in dev_markers):
issues.append("Development versions should not be released")
# Check for reasonable version format (semantic versioning)
try:
from packaging import version
version.Version(version_string)
except Exception:
issues.append("Version string is not valid according to PEP 440")
# Check if version already exists as git tag
tag_name = version_string if version_string.startswith('v') else f'v{version_string}'
if self.git_manager.tag_exists(tag_name):
issues.append(f"Git tag {tag_name} already exists")
return len(issues) == 0, issues
def _validate_changelog(self) -> List[str]:
"""Validate CHANGELOG.md using changelog schema.
Returns:
List of CHANGELOG-related issues
"""
issues = []
changelog_path = self.project_root / 'CHANGELOG.md'
# Check if CHANGELOG exists
if not changelog_path.exists():
issues.append("Missing CHANGELOG.md file")
return issues
# Check if changelog schema exists
schema_path = self.project_root / 'markitect' / 'schemas' / 'changelog-schema-v1.0.md'
if not schema_path.exists():
# Schema doesn't exist, skip validation
return issues
# Validate CHANGELOG with schema using markitect validate command
try:
result = subprocess.run(
[
'markitect', 'validate', str(changelog_path),
'--schema', str(schema_path),
'--semantic'
],
capture_output=True,
text=True,
cwd=self.project_root
)
if result.returncode != 0:
issues.append("CHANGELOG.md validation failed against schema")
# Parse output for specific errors
if 'Unreleased section' in result.stdout:
issues.append(" - Missing [Unreleased] section in CHANGELOG")
if 'version format' in result.stdout.lower():
issues.append(" - Invalid version format in CHANGELOG")
except FileNotFoundError:
# markitect command not available
issues.append("Cannot validate CHANGELOG (markitect command not found)")
except Exception as e:
issues.append(f"Error validating CHANGELOG: {e}")
return issues
def validate_changelog_version(self, version: str) -> Tuple[bool, List[str]]:
"""Validate that CHANGELOG has section for specified version.
Args:
version: Version to check (e.g., "0.10.0")
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
changelog_path = self.project_root / 'CHANGELOG.md'
if not changelog_path.exists():
issues.append("CHANGELOG.md not found")
return False, issues
try:
content = changelog_path.read_text()
# Check for version section
version_header = f"## [{version}]"
if version_header not in content:
issues.append(f"CHANGELOG missing section for version {version}")
# Check for Unreleased section
if "## [Unreleased]" not in content:
issues.append("CHANGELOG missing [Unreleased] section")
# Check if version section has a date
import re
date_pattern = rf"## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}"
if not re.search(date_pattern, content):
issues.append(f"Version {version} section missing date or has invalid date format")
except Exception as e:
issues.append(f"Error reading CHANGELOG: {e}")
return len(issues) == 0, issues
def check_version_tag_consistency(self, version: str) -> Tuple[bool, List[str]]:
"""Check consistency between CHANGELOG version and git tags.
Args:
version: Version to check (e.g., "0.10.0")
Returns:
Tuple of (is_consistent, list_of_issues)
"""
issues = []
# Check CHANGELOG has the version
changelog_valid, changelog_issues = self.validate_changelog_version(version)
if not changelog_valid:
issues.extend(changelog_issues)
# Check git tag exists
tag_name = version if version.startswith('v') else f'v{version}'
if not self.git_manager.tag_exists(tag_name):
issues.append(f"Git tag {tag_name} doesn't exist for version in CHANGELOG")
return len(issues) == 0, issues
def get_validation_summary(self) -> dict:
"""Get a comprehensive validation summary.
Returns:
Dictionary with validation results
"""
is_valid, issues = self.validate_release_state()
return {
'is_valid': is_valid,
'issues': issues,
'git_status': self.git_manager.get_repository_status(),
'recommendations': self._get_recommendations(issues)
}
def _get_recommendations(self, issues: List[str]) -> List[str]:
"""Get recommendations based on validation issues.
Args:
issues: List of validation issues
Returns:
List of recommendations
"""
recommendations = []
if any('uncommitted changes' in issue for issue in issues):
recommendations.append("Commit or stash your changes before releasing")
if any('not on main branch' in issue for issue in issues):
recommendations.append("Switch to main branch: git checkout main")
if any('setuptools-scm' in issue for issue in issues):
recommendations.append("Configure setuptools-scm in pyproject.toml")
if any('authentication' in issue.lower() for issue in issues):
recommendations.append("Set up authentication tokens for package publishing")
if any('CHANGELOG' in issue for issue in issues):
recommendations.append("Fix CHANGELOG.md format and ensure [Unreleased] section exists")
recommendations.append("Validate with: markitect validate CHANGELOG.md --schema changelog-schema-v1.0.md --semantic")
if not issues:
recommendations.append("Repository is ready for release!")
return recommendations