Complete release management toolkit including: - changelog_generator.py: Parse conventional commits and generate structured changelogs - version_bumper.py: Determine semantic version bumps from commit analysis - release_planner.py: Assess release readiness and generate coordination plans - Comprehensive documentation covering SemVer, Git workflows, hotfix procedures - Sample data and expected outputs for testing - Zero external dependencies, Python standard library only Enables automated changelog generation, version management, and release coordination from git history using conventional commits specification.
645 lines
23 KiB
Python
645 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Version Bumper
|
|
|
|
Analyzes commits since last tag to determine the correct version bump (major/minor/patch)
|
|
based on conventional commits. Handles pre-release versions (alpha, beta, rc) and generates
|
|
version bump commands for various package files.
|
|
|
|
Input: current version + commit list JSON or git log
|
|
Output: recommended new version + bump commands + updated file snippets
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from typing import Dict, List, Optional, Tuple, Union
|
|
from enum import Enum
|
|
from dataclasses import dataclass
|
|
|
|
|
|
class BumpType(Enum):
|
|
"""Version bump types."""
|
|
NONE = "none"
|
|
PATCH = "patch"
|
|
MINOR = "minor"
|
|
MAJOR = "major"
|
|
|
|
|
|
class PreReleaseType(Enum):
|
|
"""Pre-release types."""
|
|
ALPHA = "alpha"
|
|
BETA = "beta"
|
|
RC = "rc"
|
|
|
|
|
|
@dataclass
|
|
class Version:
|
|
"""Semantic version representation."""
|
|
major: int
|
|
minor: int
|
|
patch: int
|
|
prerelease_type: Optional[PreReleaseType] = None
|
|
prerelease_number: Optional[int] = None
|
|
|
|
@classmethod
|
|
def parse(cls, version_str: str) -> 'Version':
|
|
"""Parse version string into Version object."""
|
|
# Remove 'v' prefix if present
|
|
clean_version = version_str.lstrip('v')
|
|
|
|
# Pattern for semantic versioning with optional pre-release
|
|
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(\w+)\.?(\d+)?)?$'
|
|
match = re.match(pattern, clean_version)
|
|
|
|
if not match:
|
|
raise ValueError(f"Invalid version format: {version_str}")
|
|
|
|
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
|
|
prerelease_type = None
|
|
prerelease_number = None
|
|
|
|
if match.group(4): # Pre-release identifier
|
|
prerelease_str = match.group(4).lower()
|
|
try:
|
|
prerelease_type = PreReleaseType(prerelease_str)
|
|
except ValueError:
|
|
# Handle variations like 'alpha1' -> 'alpha'
|
|
if prerelease_str.startswith('alpha'):
|
|
prerelease_type = PreReleaseType.ALPHA
|
|
elif prerelease_str.startswith('beta'):
|
|
prerelease_type = PreReleaseType.BETA
|
|
elif prerelease_str.startswith('rc'):
|
|
prerelease_type = PreReleaseType.RC
|
|
else:
|
|
raise ValueError(f"Unknown pre-release type: {prerelease_str}")
|
|
|
|
if match.group(5):
|
|
prerelease_number = int(match.group(5))
|
|
else:
|
|
# Extract number from combined string like 'alpha1'
|
|
number_match = re.search(r'(\d+)$', prerelease_str)
|
|
if number_match:
|
|
prerelease_number = int(number_match.group(1))
|
|
else:
|
|
prerelease_number = 1 # Default to 1
|
|
|
|
return cls(major, minor, patch, prerelease_type, prerelease_number)
|
|
|
|
def to_string(self, include_v_prefix: bool = False) -> str:
|
|
"""Convert version to string representation."""
|
|
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
|
|
if self.prerelease_type:
|
|
if self.prerelease_number is not None:
|
|
base += f"-{self.prerelease_type.value}.{self.prerelease_number}"
|
|
else:
|
|
base += f"-{self.prerelease_type.value}"
|
|
|
|
return f"v{base}" if include_v_prefix else base
|
|
|
|
def bump(self, bump_type: BumpType, prerelease_type: Optional[PreReleaseType] = None) -> 'Version':
|
|
"""Create new version with specified bump."""
|
|
if bump_type == BumpType.NONE:
|
|
return Version(self.major, self.minor, self.patch, self.prerelease_type, self.prerelease_number)
|
|
|
|
new_major = self.major
|
|
new_minor = self.minor
|
|
new_patch = self.patch
|
|
new_prerelease_type = None
|
|
new_prerelease_number = None
|
|
|
|
# Handle pre-release versions
|
|
if prerelease_type:
|
|
if bump_type == BumpType.MAJOR:
|
|
new_major += 1
|
|
new_minor = 0
|
|
new_patch = 0
|
|
elif bump_type == BumpType.MINOR:
|
|
new_minor += 1
|
|
new_patch = 0
|
|
elif bump_type == BumpType.PATCH:
|
|
new_patch += 1
|
|
|
|
new_prerelease_type = prerelease_type
|
|
new_prerelease_number = 1
|
|
|
|
# Handle existing pre-release -> next pre-release
|
|
elif self.prerelease_type:
|
|
# If we're already in pre-release, increment or promote
|
|
if prerelease_type is None:
|
|
# Promote to stable release
|
|
# Don't change version numbers, just remove pre-release
|
|
pass
|
|
else:
|
|
# Move to next pre-release type or increment
|
|
if prerelease_type == self.prerelease_type:
|
|
# Same pre-release type, increment number
|
|
new_prerelease_type = self.prerelease_type
|
|
new_prerelease_number = (self.prerelease_number or 0) + 1
|
|
else:
|
|
# Different pre-release type
|
|
new_prerelease_type = prerelease_type
|
|
new_prerelease_number = 1
|
|
|
|
# Handle stable version bumps
|
|
else:
|
|
if bump_type == BumpType.MAJOR:
|
|
new_major += 1
|
|
new_minor = 0
|
|
new_patch = 0
|
|
elif bump_type == BumpType.MINOR:
|
|
new_minor += 1
|
|
new_patch = 0
|
|
elif bump_type == BumpType.PATCH:
|
|
new_patch += 1
|
|
|
|
return Version(new_major, new_minor, new_patch, new_prerelease_type, new_prerelease_number)
|
|
|
|
|
|
@dataclass
|
|
class ConventionalCommit:
|
|
"""Represents a parsed conventional commit for version analysis."""
|
|
type: str
|
|
scope: str
|
|
description: str
|
|
is_breaking: bool
|
|
breaking_description: str
|
|
hash: str = ""
|
|
author: str = ""
|
|
date: str = ""
|
|
|
|
@classmethod
|
|
def parse_message(cls, message: str, commit_hash: str = "",
|
|
author: str = "", date: str = "") -> 'ConventionalCommit':
|
|
"""Parse conventional commit message."""
|
|
lines = message.split('\n')
|
|
header = lines[0] if lines else ""
|
|
|
|
# Parse header: type(scope): description
|
|
header_pattern = r'^(\w+)(\([^)]+\))?(!)?:\s*(.+)$'
|
|
match = re.match(header_pattern, header)
|
|
|
|
commit_type = "chore"
|
|
scope = ""
|
|
description = header
|
|
is_breaking = False
|
|
breaking_description = ""
|
|
|
|
if match:
|
|
commit_type = match.group(1).lower()
|
|
scope_match = match.group(2)
|
|
scope = scope_match[1:-1] if scope_match else ""
|
|
is_breaking = bool(match.group(3)) # ! indicates breaking change
|
|
description = match.group(4).strip()
|
|
|
|
# Check for breaking change in body/footers
|
|
if len(lines) > 1:
|
|
body_text = '\n'.join(lines[1:])
|
|
if 'BREAKING CHANGE:' in body_text:
|
|
is_breaking = True
|
|
breaking_match = re.search(r'BREAKING CHANGE:\s*(.+)', body_text)
|
|
if breaking_match:
|
|
breaking_description = breaking_match.group(1).strip()
|
|
|
|
return cls(commit_type, scope, description, is_breaking, breaking_description,
|
|
commit_hash, author, date)
|
|
|
|
|
|
class VersionBumper:
|
|
"""Main version bumping logic."""
|
|
|
|
def __init__(self):
|
|
self.current_version: Optional[Version] = None
|
|
self.commits: List[ConventionalCommit] = []
|
|
self.custom_rules: Dict[str, BumpType] = {}
|
|
self.ignore_types: List[str] = ['test', 'ci', 'build', 'chore', 'docs', 'style']
|
|
|
|
def set_current_version(self, version_str: str):
|
|
"""Set the current version."""
|
|
self.current_version = Version.parse(version_str)
|
|
|
|
def add_custom_rule(self, commit_type: str, bump_type: BumpType):
|
|
"""Add custom rule for commit type to bump type mapping."""
|
|
self.custom_rules[commit_type] = bump_type
|
|
|
|
def parse_commits_from_json(self, json_data: Union[str, List[Dict]]):
|
|
"""Parse commits from JSON format."""
|
|
if isinstance(json_data, str):
|
|
data = json.loads(json_data)
|
|
else:
|
|
data = json_data
|
|
|
|
self.commits = []
|
|
for commit_data in data:
|
|
commit = ConventionalCommit.parse_message(
|
|
message=commit_data.get('message', ''),
|
|
commit_hash=commit_data.get('hash', ''),
|
|
author=commit_data.get('author', ''),
|
|
date=commit_data.get('date', '')
|
|
)
|
|
self.commits.append(commit)
|
|
|
|
def parse_commits_from_git_log(self, git_log_text: str):
|
|
"""Parse commits from git log output."""
|
|
lines = git_log_text.strip().split('\n')
|
|
|
|
if not lines or not lines[0]:
|
|
return
|
|
|
|
# Simple oneline format (hash message)
|
|
oneline_pattern = r'^([a-f0-9]{7,40})\s+(.+)$'
|
|
|
|
self.commits = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
match = re.match(oneline_pattern, line)
|
|
if match:
|
|
commit_hash = match.group(1)
|
|
message = match.group(2)
|
|
commit = ConventionalCommit.parse_message(message, commit_hash)
|
|
self.commits.append(commit)
|
|
|
|
def determine_bump_type(self) -> BumpType:
|
|
"""Determine version bump type based on commits."""
|
|
if not self.commits:
|
|
return BumpType.NONE
|
|
|
|
has_breaking = False
|
|
has_feature = False
|
|
has_fix = False
|
|
|
|
for commit in self.commits:
|
|
# Check for breaking changes
|
|
if commit.is_breaking:
|
|
has_breaking = True
|
|
continue
|
|
|
|
# Apply custom rules first
|
|
if commit.type in self.custom_rules:
|
|
bump_type = self.custom_rules[commit.type]
|
|
if bump_type == BumpType.MAJOR:
|
|
has_breaking = True
|
|
elif bump_type == BumpType.MINOR:
|
|
has_feature = True
|
|
elif bump_type == BumpType.PATCH:
|
|
has_fix = True
|
|
continue
|
|
|
|
# Standard rules
|
|
if commit.type in ['feat', 'add']:
|
|
has_feature = True
|
|
elif commit.type in ['fix', 'security', 'perf', 'bugfix']:
|
|
has_fix = True
|
|
# Ignore types in ignore_types list
|
|
|
|
# Determine bump type by priority
|
|
if has_breaking:
|
|
return BumpType.MAJOR
|
|
elif has_feature:
|
|
return BumpType.MINOR
|
|
elif has_fix:
|
|
return BumpType.PATCH
|
|
else:
|
|
return BumpType.NONE
|
|
|
|
def recommend_version(self, prerelease_type: Optional[PreReleaseType] = None) -> Version:
|
|
"""Recommend new version based on commits."""
|
|
if not self.current_version:
|
|
raise ValueError("Current version not set")
|
|
|
|
bump_type = self.determine_bump_type()
|
|
return self.current_version.bump(bump_type, prerelease_type)
|
|
|
|
def generate_bump_commands(self, new_version: Version) -> Dict[str, List[str]]:
|
|
"""Generate version bump commands for different package managers."""
|
|
version_str = new_version.to_string()
|
|
version_with_v = new_version.to_string(include_v_prefix=True)
|
|
|
|
commands = {
|
|
'npm': [
|
|
f"npm version {version_str} --no-git-tag-version",
|
|
f"# Or manually edit package.json version field to '{version_str}'"
|
|
],
|
|
'python': [
|
|
f"# Update version in setup.py, __init__.py, or pyproject.toml",
|
|
f"# setup.py: version='{version_str}'",
|
|
f"# pyproject.toml: version = '{version_str}'",
|
|
f"# __init__.py: __version__ = '{version_str}'"
|
|
],
|
|
'rust': [
|
|
f"# Update Cargo.toml",
|
|
f"# [package]",
|
|
f"# version = '{version_str}'"
|
|
],
|
|
'git': [
|
|
f"git tag -a {version_with_v} -m 'Release {version_with_v}'",
|
|
f"git push origin {version_with_v}"
|
|
],
|
|
'docker': [
|
|
f"docker build -t myapp:{version_str} .",
|
|
f"docker tag myapp:{version_str} myapp:latest"
|
|
]
|
|
}
|
|
|
|
return commands
|
|
|
|
def generate_file_updates(self, new_version: Version) -> Dict[str, str]:
|
|
"""Generate file update snippets for common package files."""
|
|
version_str = new_version.to_string()
|
|
|
|
updates = {}
|
|
|
|
# package.json
|
|
updates['package.json'] = json.dumps({
|
|
"name": "your-package",
|
|
"version": version_str,
|
|
"description": "Your package description",
|
|
"main": "index.js"
|
|
}, indent=2)
|
|
|
|
# pyproject.toml
|
|
updates['pyproject.toml'] = f'''[build-system]
|
|
requires = ["setuptools>=61.0", "wheel"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[project]
|
|
name = "your-package"
|
|
version = "{version_str}"
|
|
description = "Your package description"
|
|
authors = [
|
|
{{name = "Your Name", email = "your.email@example.com"}},
|
|
]
|
|
'''
|
|
|
|
# setup.py
|
|
updates['setup.py'] = f'''from setuptools import setup, find_packages
|
|
|
|
setup(
|
|
name="your-package",
|
|
version="{version_str}",
|
|
description="Your package description",
|
|
packages=find_packages(),
|
|
python_requires=">=3.8",
|
|
)
|
|
'''
|
|
|
|
# Cargo.toml
|
|
updates['Cargo.toml'] = f'''[package]
|
|
name = "your-package"
|
|
version = "{version_str}"
|
|
edition = "2021"
|
|
description = "Your package description"
|
|
'''
|
|
|
|
# __init__.py
|
|
updates['__init__.py'] = f'''"""Your package."""
|
|
|
|
__version__ = "{version_str}"
|
|
__author__ = "Your Name"
|
|
__email__ = "your.email@example.com"
|
|
'''
|
|
|
|
return updates
|
|
|
|
def analyze_commits(self) -> Dict:
|
|
"""Provide detailed analysis of commits for version bumping."""
|
|
if not self.commits:
|
|
return {
|
|
'total_commits': 0,
|
|
'by_type': {},
|
|
'breaking_changes': [],
|
|
'features': [],
|
|
'fixes': [],
|
|
'ignored': []
|
|
}
|
|
|
|
analysis = {
|
|
'total_commits': len(self.commits),
|
|
'by_type': {},
|
|
'breaking_changes': [],
|
|
'features': [],
|
|
'fixes': [],
|
|
'ignored': []
|
|
}
|
|
|
|
type_counts = {}
|
|
for commit in self.commits:
|
|
type_counts[commit.type] = type_counts.get(commit.type, 0) + 1
|
|
|
|
if commit.is_breaking:
|
|
analysis['breaking_changes'].append({
|
|
'type': commit.type,
|
|
'scope': commit.scope,
|
|
'description': commit.description,
|
|
'breaking_description': commit.breaking_description,
|
|
'hash': commit.hash
|
|
})
|
|
elif commit.type in ['feat', 'add']:
|
|
analysis['features'].append({
|
|
'scope': commit.scope,
|
|
'description': commit.description,
|
|
'hash': commit.hash
|
|
})
|
|
elif commit.type in ['fix', 'security', 'perf', 'bugfix']:
|
|
analysis['fixes'].append({
|
|
'scope': commit.scope,
|
|
'description': commit.description,
|
|
'hash': commit.hash
|
|
})
|
|
elif commit.type in self.ignore_types:
|
|
analysis['ignored'].append({
|
|
'type': commit.type,
|
|
'scope': commit.scope,
|
|
'description': commit.description,
|
|
'hash': commit.hash
|
|
})
|
|
|
|
analysis['by_type'] = type_counts
|
|
return analysis
|
|
|
|
|
|
def main():
|
|
"""Main CLI entry point."""
|
|
parser = argparse.ArgumentParser(description="Determine version bump based on conventional commits")
|
|
parser.add_argument('--current-version', '-c', required=True,
|
|
help='Current version (e.g., 1.2.3, v1.2.3)')
|
|
parser.add_argument('--input', '-i', type=str,
|
|
help='Input file with commits (default: stdin)')
|
|
parser.add_argument('--input-format', choices=['git-log', 'json'],
|
|
default='git-log', help='Input format')
|
|
parser.add_argument('--prerelease', '-p',
|
|
choices=['alpha', 'beta', 'rc'],
|
|
help='Generate pre-release version')
|
|
parser.add_argument('--output-format', '-f',
|
|
choices=['text', 'json', 'commands'],
|
|
default='text', help='Output format')
|
|
parser.add_argument('--output', '-o', type=str,
|
|
help='Output file (default: stdout)')
|
|
parser.add_argument('--include-commands', action='store_true',
|
|
help='Include bump commands in output')
|
|
parser.add_argument('--include-files', action='store_true',
|
|
help='Include file update snippets')
|
|
parser.add_argument('--custom-rules', type=str,
|
|
help='JSON string with custom type->bump rules')
|
|
parser.add_argument('--ignore-types', type=str,
|
|
help='Comma-separated list of types to ignore')
|
|
parser.add_argument('--analysis', '-a', action='store_true',
|
|
help='Include detailed commit analysis')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Read input
|
|
if args.input:
|
|
with open(args.input, 'r', encoding='utf-8') as f:
|
|
input_data = f.read()
|
|
else:
|
|
input_data = sys.stdin.read()
|
|
|
|
if not input_data.strip():
|
|
print("No input data provided", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Initialize version bumper
|
|
bumper = VersionBumper()
|
|
|
|
try:
|
|
bumper.set_current_version(args.current_version)
|
|
except ValueError as e:
|
|
print(f"Invalid current version: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Apply custom rules
|
|
if args.custom_rules:
|
|
try:
|
|
custom_rules = json.loads(args.custom_rules)
|
|
for commit_type, bump_type_str in custom_rules.items():
|
|
bump_type = BumpType(bump_type_str.lower())
|
|
bumper.add_custom_rule(commit_type, bump_type)
|
|
except Exception as e:
|
|
print(f"Invalid custom rules: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Set ignore types
|
|
if args.ignore_types:
|
|
bumper.ignore_types = [t.strip() for t in args.ignore_types.split(',')]
|
|
|
|
# Parse commits
|
|
try:
|
|
if args.input_format == 'json':
|
|
bumper.parse_commits_from_json(input_data)
|
|
else:
|
|
bumper.parse_commits_from_git_log(input_data)
|
|
except Exception as e:
|
|
print(f"Error parsing commits: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Determine pre-release type
|
|
prerelease_type = None
|
|
if args.prerelease:
|
|
prerelease_type = PreReleaseType(args.prerelease)
|
|
|
|
# Generate recommendation
|
|
try:
|
|
recommended_version = bumper.recommend_version(prerelease_type)
|
|
bump_type = bumper.determine_bump_type()
|
|
except Exception as e:
|
|
print(f"Error determining version: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Generate output
|
|
output_data = {}
|
|
|
|
if args.output_format == 'json':
|
|
output_data = {
|
|
'current_version': args.current_version,
|
|
'recommended_version': recommended_version.to_string(),
|
|
'recommended_version_with_v': recommended_version.to_string(include_v_prefix=True),
|
|
'bump_type': bump_type.value,
|
|
'prerelease': args.prerelease
|
|
}
|
|
|
|
if args.analysis:
|
|
output_data['analysis'] = bumper.analyze_commits()
|
|
|
|
if args.include_commands:
|
|
output_data['commands'] = bumper.generate_bump_commands(recommended_version)
|
|
|
|
if args.include_files:
|
|
output_data['file_updates'] = bumper.generate_file_updates(recommended_version)
|
|
|
|
output_text = json.dumps(output_data, indent=2)
|
|
|
|
elif args.output_format == 'commands':
|
|
commands = bumper.generate_bump_commands(recommended_version)
|
|
output_lines = [
|
|
f"# Version Bump Commands",
|
|
f"# Current: {args.current_version}",
|
|
f"# New: {recommended_version.to_string()}",
|
|
f"# Bump Type: {bump_type.value}",
|
|
""
|
|
]
|
|
|
|
for category, cmd_list in commands.items():
|
|
output_lines.append(f"## {category.upper()}")
|
|
for cmd in cmd_list:
|
|
output_lines.append(cmd)
|
|
output_lines.append("")
|
|
|
|
output_text = '\n'.join(output_lines)
|
|
|
|
else: # text format
|
|
output_lines = [
|
|
f"Current Version: {args.current_version}",
|
|
f"Recommended Version: {recommended_version.to_string()}",
|
|
f"With v prefix: {recommended_version.to_string(include_v_prefix=True)}",
|
|
f"Bump Type: {bump_type.value}",
|
|
""
|
|
]
|
|
|
|
if args.analysis:
|
|
analysis = bumper.analyze_commits()
|
|
output_lines.extend([
|
|
"Commit Analysis:",
|
|
f"- Total commits: {analysis['total_commits']}",
|
|
f"- Breaking changes: {len(analysis['breaking_changes'])}",
|
|
f"- New features: {len(analysis['features'])}",
|
|
f"- Bug fixes: {len(analysis['fixes'])}",
|
|
f"- Ignored commits: {len(analysis['ignored'])}",
|
|
""
|
|
])
|
|
|
|
if analysis['breaking_changes']:
|
|
output_lines.append("Breaking Changes:")
|
|
for change in analysis['breaking_changes']:
|
|
scope = f"({change['scope']})" if change['scope'] else ""
|
|
output_lines.append(f" - {change['type']}{scope}: {change['description']}")
|
|
output_lines.append("")
|
|
|
|
if args.include_commands:
|
|
commands = bumper.generate_bump_commands(recommended_version)
|
|
output_lines.append("Bump Commands:")
|
|
for category, cmd_list in commands.items():
|
|
output_lines.append(f" {category}:")
|
|
for cmd in cmd_list:
|
|
if not cmd.startswith('#'):
|
|
output_lines.append(f" {cmd}")
|
|
output_lines.append("")
|
|
|
|
output_text = '\n'.join(output_lines)
|
|
|
|
# Write output
|
|
if args.output:
|
|
with open(args.output, 'w', encoding='utf-8') as f:
|
|
f.write(output_text)
|
|
else:
|
|
print(output_text)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |