Files
MokoCassiopeia/scripts/maintenance/release_version.py

452 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: MokoStandards
INGROUP: MokoStandards.Scripts
REPO: https://github.com/mokoconsulting-tech/MokoStandards/
VERSION: 05.00.00
PATH: ./scripts/release_version.py
BRIEF: Script to release a version by moving UNRELEASED items to versioned section
NOTE: Updates CHANGELOG.md and optionally updates VERSION in files
"""
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
class VersionReleaser:
"""Manages version releases in CHANGELOG.md and updates VERSION in files."""
UNRELEASED_PATTERN = r"## \[Unreleased\]" # Standard Keep a Changelog format
VERSION_PATTERN = r"## \[(\d+\.\d+\.\d+)\]"
VERSION_HEADER_PATTERN = r"VERSION:\s*(\d+\.\d+\.\d+)"
CHANGELOG_H1_PATTERN = r"^# CHANGELOG - .+ \(VERSION: (\d+\.\d+\.\d+)\)" # H1 format
def __init__(self, changelog_path: Path, repo_root: Path):
"""
Initialize the version releaser.
Args:
changelog_path: Path to CHANGELOG.md file
repo_root: Path to repository root
"""
self.changelog_path = changelog_path
self.repo_root = repo_root
self.lines: List[str] = []
def read_changelog(self) -> bool:
"""Read the changelog file."""
try:
with open(self.changelog_path, "r", encoding="utf-8") as f:
self.lines = f.readlines()
return True
except FileNotFoundError:
print(f"Error: CHANGELOG.md not found at {self.changelog_path}", file=sys.stderr)
return False
except Exception as e:
print(f"Error reading CHANGELOG.md: {e}", file=sys.stderr)
return False
def write_changelog(self) -> bool:
"""Write the updated changelog back to file."""
try:
with open(self.changelog_path, "w", encoding="utf-8") as f:
f.writelines(self.lines)
return True
except Exception as e:
print(f"Error writing CHANGELOG.md: {e}", file=sys.stderr)
return False
def find_unreleased_section(self) -> Optional[int]:
"""Find the UNRELEASED section in the changelog."""
for i, line in enumerate(self.lines):
if re.match(self.UNRELEASED_PATTERN, line):
return i
return None
def find_next_version_section(self, start_index: int) -> Optional[int]:
"""Find the next version section after a given index."""
for i in range(start_index + 1, len(self.lines)):
if re.match(self.VERSION_PATTERN, self.lines[i]):
return i
return None
def has_unreleased_content(self, unreleased_index: int, next_version_index: Optional[int]) -> bool:
"""Check if UNRELEASED section has any content."""
end_index = next_version_index if next_version_index else len(self.lines)
for i in range(unreleased_index + 1, end_index):
line = self.lines[i].strip()
# Skip empty lines and headers
if line and not line.startswith("##"):
return True
return False
def validate_version(self, version: str) -> bool:
"""Validate version format (XX.YY.ZZ)."""
pattern = r"^\d{2}\.\d{2}\.\d{2}$"
return bool(re.match(pattern, version))
def release_version(self, version: str, date: Optional[str] = None) -> bool:
"""
Move UNRELEASED content to a new version section.
Args:
version: Version number (XX.YY.ZZ format)
date: Release date (YYYY-MM-DD format), defaults to today
Returns:
True if successful, False otherwise
"""
if not self.validate_version(version):
print(f"Error: Invalid version format '{version}'. Must be XX.YY.ZZ (e.g., 05.01.00)",
file=sys.stderr)
return False
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
unreleased_index = self.find_unreleased_section()
if unreleased_index is None:
print("Error: UNRELEASED section not found in CHANGELOG.md", file=sys.stderr)
return False
next_version_index = self.find_next_version_section(unreleased_index)
# Check if UNRELEASED has content
if not self.has_unreleased_content(unreleased_index, next_version_index):
print("Warning: UNRELEASED section is empty. Nothing to release.", file=sys.stderr)
return False
# Get the content between UNRELEASED and next version
if next_version_index:
unreleased_content = self.lines[unreleased_index + 1:next_version_index]
else:
unreleased_content = self.lines[unreleased_index + 1:]
# Remove the old UNRELEASED content
if next_version_index:
del self.lines[unreleased_index + 1:next_version_index]
else:
del self.lines[unreleased_index + 1:]
# Insert new version section after UNRELEASED
new_version_lines = [
"\n",
f"## [{version}] - {date}\n"
]
new_version_lines.extend(unreleased_content)
# Insert after UNRELEASED heading
insert_index = unreleased_index + 1
for line in reversed(new_version_lines):
self.lines.insert(insert_index, line)
# Update H1 header version
self.update_changelog_h1_version(version)
return True
def update_changelog_h1_version(self, version: str) -> bool:
"""
Update the version in the H1 header of CHANGELOG.
Format: # CHANGELOG - RepoName (VERSION: X.Y.Z)
Args:
version: New version number
Returns:
True if updated, False otherwise
"""
for i, line in enumerate(self.lines):
if re.match(self.CHANGELOG_H1_PATTERN, line):
# Extract repo name from current H1
match = re.match(r"^# CHANGELOG - (.+) \(VERSION: \d+\.\d+\.\d+\)", line)
if match:
repo_name = match.group(1)
self.lines[i] = f"# CHANGELOG - {repo_name} (VERSION: {version})\n"
return True
return False
def update_file_versions(self, version: str, dry_run: bool = False) -> List[Path]:
"""
Update VERSION in all files in the repository.
Args:
version: New version number
dry_run: If True, don't actually update files
Returns:
List of files that were (or would be) updated
"""
updated_files = []
# Find all markdown, Python, and text files
patterns = ["**/*.md", "**/*.py", "**/*.txt", "**/*.yml", "**/*.yaml"]
files_to_check = []
for pattern in patterns:
files_to_check.extend(self.repo_root.glob(pattern))
for file_path in files_to_check:
# Skip certain directories
skip_dirs = [".git", "node_modules", "vendor", "__pycache__", ".venv"]
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Check if file has VERSION header
if re.search(self.VERSION_HEADER_PATTERN, content):
new_content = re.sub(
self.VERSION_HEADER_PATTERN,
f"VERSION: {version}",
content
)
if new_content != content:
if not dry_run:
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_content)
updated_files.append(file_path.relative_to(self.repo_root))
except (UnicodeDecodeError, PermissionError):
# Skip binary files or files we can't read
continue
except Exception as e:
print(f"Warning: Error processing {file_path}: {e}", file=sys.stderr)
continue
return updated_files
def extract_release_notes(self, version: str) -> Optional[str]:
"""
Extract release notes for a specific version from CHANGELOG.
Args:
version: Version number to extract notes for
Returns:
Release notes content or None if not found
"""
version_pattern = rf"## \[{re.escape(version)}\]"
notes_lines = []
in_version = False
for line in self.lines:
if re.match(version_pattern, line):
in_version = True
continue
elif in_version:
# Stop at next version heading
if line.startswith("## ["):
break
notes_lines.append(line)
if notes_lines:
return "".join(notes_lines).strip()
return None
def create_github_release(self, version: str, dry_run: bool = False) -> bool:
"""
Create a GitHub release using gh CLI.
Args:
version: Version number
dry_run: If True, don't actually create release
Returns:
True if successful, False otherwise
"""
# Check if gh CLI is available
try:
subprocess.run(["gh", "--version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Warning: gh CLI not found. Skipping GitHub release creation.", file=sys.stderr)
print("Install gh CLI: https://cli.github.com/", file=sys.stderr)
return False
# Extract release notes from changelog
release_notes = self.extract_release_notes(version)
if not release_notes:
print(f"Warning: Could not extract release notes for version {version}", file=sys.stderr)
release_notes = f"Release {version}"
tag_name = f"v{version}"
title = f"Release {version}"
if dry_run:
print(f"\n[DRY RUN] Would create GitHub release:")
print(f" Tag: {tag_name}")
print(f" Title: {title}")
print(f" Notes:\n{release_notes[:200]}...")
return True
try:
# Create the release
cmd = [
"gh", "release", "create", tag_name,
"--title", title,
"--notes", release_notes
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
print(f"\nSuccessfully created GitHub release: {tag_name}")
print(f"Release URL: {result.stdout.strip()}")
return True
except subprocess.CalledProcessError as e:
print(f"Error creating GitHub release: {e.stderr}", file=sys.stderr)
return False
def main() -> int:
"""Main entry point for the version release script."""
parser = argparse.ArgumentParser(
description="Release a version by moving UNRELEASED items to versioned section",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Release version 05.01.00 with today's date
%(prog)s --version 05.01.00
# Release version with specific date
%(prog)s --version 05.01.00 --date 2026-01-15
# Release and update VERSION in all files
%(prog)s --version 05.01.00 --update-files
# Release, update files, and create GitHub release
%(prog)s --version 05.01.00 --update-files --create-release
# Dry run to see what would be updated
%(prog)s --version 05.01.00 --update-files --create-release --dry-run
Version format: XX.YY.ZZ (e.g., 05.01.00)
"""
)
parser.add_argument(
"--version",
type=str,
required=True,
help="Version number in XX.YY.ZZ format (e.g., 05.01.00)"
)
parser.add_argument(
"--date",
type=str,
help="Release date in YYYY-MM-DD format (defaults to today)"
)
parser.add_argument(
"--changelog",
type=Path,
default=Path("CHANGELOG.md"),
help="Path to CHANGELOG.md file (default: ./CHANGELOG.md)"
)
parser.add_argument(
"--update-files",
action="store_true",
help="Update VERSION header in all repository files"
)
parser.add_argument(
"--create-release",
action="store_true",
help="Create a GitHub release using gh CLI"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes"
)
args = parser.parse_args()
# Find repository root
current_dir = Path.cwd()
repo_root = current_dir
while repo_root.parent != repo_root:
if (repo_root / ".git").exists():
break
repo_root = repo_root.parent
else:
repo_root = current_dir
# Resolve changelog path
if not args.changelog.is_absolute():
changelog_path = repo_root / args.changelog
else:
changelog_path = args.changelog
releaser = VersionReleaser(changelog_path, repo_root)
if not releaser.read_changelog():
return 1
# Release the version
if args.dry_run:
print(f"[DRY RUN] Would release version {args.version}")
else:
if releaser.release_version(args.version, args.date):
if releaser.write_changelog():
print(f"Successfully released version {args.version} in CHANGELOG.md")
else:
return 1
else:
return 1
# Update file versions if requested
if args.update_files:
updated_files = releaser.update_file_versions(args.version, args.dry_run)
if updated_files:
if args.dry_run:
print(f"\n[DRY RUN] Would update VERSION in {len(updated_files)} files:")
else:
print(f"\nUpdated VERSION to {args.version} in {len(updated_files)} files:")
for file_path in sorted(updated_files):
print(f" - {file_path}")
else:
print("\nNo files with VERSION headers found to update.")
# Create GitHub release if requested
if args.create_release:
if not releaser.create_github_release(args.version, args.dry_run):
print("\nNote: GitHub release creation failed or was skipped.", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())