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>
350 lines
12 KiB
Python
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 |