#!/usr/bin/env python3 """Release script for st-messages project. This script automates the release process by: - Validating the release version (semver format) - Optionally calculating the next version automatically - Updating version files (pyproject.toml, package.json) - Updating the CHANGELOG - Creating a release branch and pushing it """ from __future__ import annotations import argparse import datetime import re import subprocess import sys from pathlib import Path from typing import Literal, Optional def run_command( cmd: str, shell: bool = False, capture_output: bool = False ) -> subprocess.CompletedProcess: """Run a shell command and return the result.""" if capture_output: result = subprocess.run( cmd, shell=shell, capture_output=True, text=True, check=False ) return result else: subprocess.run(cmd, shell=shell, check=True) return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") RELEASE_KINDS = {"p": "patch", "m": "minor", "mj": "major"} ReleaseKind = Literal["p", "m", "mj"] SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") def get_current_version() -> str | None: """Extract current version from pyproject.toml.""" path = Path("src/backend/pyproject.toml") if not path.exists(): return None content = path.read_text() match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) return match.group(1) if match else None def calculate_next_version(current: str, kind: ReleaseKind) -> str: """Calculate next version based on release kind.""" match = SEMVER_PATTERN.match(current) if not match: raise ValueError(f"Current version '{current}' is not valid semver") major, minor, patch = map(int, match.groups()) if kind == "mj": return f"{major + 1}.0.0" elif kind == "m": return f"{major}.{minor + 1}.0" else: # patch return f"{major}.{minor}.{patch + 1}" def validate_version(version: str) -> bool: """Validate that version follows semver format.""" return bool(SEMVER_PATTERN.match(version)) def check_git_status() -> tuple[bool, str]: """Check if git working directory is clean and on main branch.""" # Check for uncommitted changes result = run_command("git status --porcelain", shell=True, capture_output=True) if result.stdout.strip(): return False, "Working directory has uncommitted changes" # Check current branch result = run_command("git branch --show-current", shell=True, capture_output=True) current_branch = result.stdout.strip() if current_branch != "main": return False, f"Not on main branch (currently on '{current_branch}')" return True, "" def check_changelog_has_unreleased() -> bool: """Check if CHANGELOG has entries in Unreleased section.""" path = Path("CHANGELOG.md") if not path.exists(): return False content = path.read_text() # Find content between [Unreleased] and the next ## section match = re.search(r"## \[Unreleased\]\s*\n(.*?)(?=\n## \[|$)", content, re.DOTALL) if not match: return False unreleased_content = match.group(1).strip() # Check if there's actual content (not just empty lines) return bool(unreleased_content) def update_files(version: str) -> None: """Update all files needed with new release version.""" # pyproject.toml sys.stdout.write("Updating pyproject.toml files...\n") src_path = Path("src") for pyproject_toml in src_path.rglob("pyproject.toml"): content = pyproject_toml.read_text() content = re.sub( r'^(version\s*=\s*)"[^"]+"', f'\\1"{version}"', content, flags=re.MULTILINE ) pyproject_toml.write_text(content) sys.stdout.write(f" → {pyproject_toml}\n") # frontend and e2e package.json files sys.stdout.write("Updating package.json files...\n") for package_json in src_path.rglob("package.json"): if "node_modules" in package_json.parts or ".next" in package_json.parts: continue content = package_json.read_text() content = re.sub(r'"version":\s*"[^"]+"', f'"version": "{version}"', content) package_json.write_text(content) sys.stdout.write(f" → {package_json}\n") # Update uv.lock files to match sys.stdout.write("Updating uv.lock files...\n") for pyproject_toml in src_path.rglob("pyproject.toml"): lock_file = pyproject_toml.parent / "uv.lock" if lock_file.exists(): run_command( f"cd {pyproject_toml.parent} && uv lock", shell=True ) sys.stdout.write(f" → {lock_file}\n") # Update package-lock.json files to match sys.stdout.write("Updating package-lock.json files...\n") for package_json in src_path.rglob("package.json"): if "node_modules" in package_json.parts or ".next" in package_json.parts: continue package_dir = package_json.parent lock_file = package_dir / "package-lock.json" if lock_file.exists(): run_command( f"cd {package_dir} && npm install --package-lock-only --ignore-scripts", shell=True ) sys.stdout.write(f" → {lock_file}\n") def update_changelog(version: str) -> None: """Update changelog file with release info.""" sys.stdout.write("Updating CHANGELOG.md...\n") path = Path("CHANGELOG.md") lines = path.read_text().splitlines(keepends=True) today = datetime.date.today() new_lines = [] for i, line in enumerate(lines): new_lines.append(line) # Add new version header after [Unreleased] if "## [Unreleased]" in line: new_lines.append(f"\n## [{version}] - {today}\n") # Update comparison links at the bottom if line.startswith("[unreleased]"): last_version_match = re.search( r"\[(\d+\.\d+\.\d+)\]", lines[i + 1] if i + 1 < len(lines) else "" ) if last_version_match: last_version = last_version_match.group(1) new_unreleased_line = line.replace(last_version, version) new_release_line = ( lines[i + 1].replace(last_version, version) if i + 1 < len(lines) else "" ) new_lines[-1] = new_unreleased_line new_lines.append(new_release_line) path.write_text("".join(new_lines)) def create_release(version: str, kind: ReleaseKind, dry_run: bool = False) -> None: """Create release branch and push.""" branch_name = f"release/{version}" if dry_run: sys.stdout.write(f"\n[DRY-RUN] Would create branch: {branch_name}\n") sys.stdout.write("[DRY-RUN] Would update version files\n") sys.stdout.write("[DRY-RUN] Would update CHANGELOG\n") sys.stdout.write(f"[DRY-RUN] Would push to origin/{branch_name}\n") return sys.stdout.write(f"\nCreating release branch: {branch_name}\n") run_command(f"git checkout -b {branch_name}", shell=True) run_command("git pull --rebase origin main", shell=True) update_changelog(version) update_files(version) run_command("git add CHANGELOG.md", shell=True) run_command("git add src/", shell=True) message = f"""🔖({RELEASE_KINDS[kind]}) release version {version} Update all version files and changelog for {RELEASE_KINDS[kind]} release.""" run_command(f"git commit -m '{message}'", shell=True) confirm = input( f""" \033[0;32m### RELEASE ### Ready to push branch '{branch_name}' to origin. Continue? (y/n): \x1b[0m""" ).strip().lower() if confirm == "y": run_command(f"git push origin {branch_name}", shell=True) print("\033[1;34m✅ Release branch pushed successfully!\x1b[0m") print("\n\033[1;34mPLEASE DO MANUALLY THE FOLLOWING STEPS:\x1b[0m") else: sys.stdout.write("\n⚠️ Push cancelled. Branch created locally.\n") print("\n\033[1;34mPLEASE DO MANUALLY THE FOLLOWING STEPS:\x1b[0m") print("\033[1;34m- Push the release branch:\x1b[0m") print(f"\033[1;34m>> git push origin {branch_name}\x1b[0m") print_next_steps_after_release_branch_pushed(version, branch_name) def print_next_steps_after_release_branch_pushed(version: str, branch_name: str) -> None: """Print next steps after release branch is pushed.""" sys.stdout.write( f"""\033[1;34m- Create PR: https://github.com/suitenumerique/messages/compare/{branch_name} - After merge, tag the release: >> git checkout main >> git pull >> git tag v{version} >> git push origin v{version} \x1b[0m""" ) def main() -> None: """Main entry point.""" parser = argparse.ArgumentParser(description="Create a new release for st-messages") parser.add_argument("--version", "-v", help="Release version (semver format)") parser.add_argument("--kind", "-k", choices=RELEASE_KINDS.keys(), help="Release kind") parser.add_argument( "--dry-run", "-n", action="store_true", help="Show what would be done" ) parser.add_argument( "--skip-checks", action="store_true", help="Skip git status checks" ) args = parser.parse_args() # Check git status if not args.skip_checks: is_clean, error = check_git_status() if not is_clean: sys.stderr.write(f"\033[0;31m❌ {error}\033[0m\n") sys.stderr.write("Use --skip-checks to bypass this check.\n") sys.exit(1) # Get current version current_version = get_current_version() if current_version: sys.stdout.write(f"Current version: {current_version}\n") # Get release kind kind = args.kind while kind not in RELEASE_KINDS: kind = input("Release kind (p=patch, m=minor, mj=major): ").strip() # Get or calculate version version = args.version if not version and current_version: suggested = calculate_next_version(current_version, kind) version = input(f"Version [{suggested}]: ").strip() or suggested while not version or not validate_version(version): if version: sys.stdout.write(f"❌ Invalid version format: '{version}' (expected: X.Y.Z)\n") version = input("Enter release version (X.Y.Z): ").strip() # Check changelog if not check_changelog_has_unreleased(): sys.stdout.write( "\033[0;33m⚠️ Warning: No entries found in CHANGELOG [Unreleased] section\033[0m\n" ) if input("Continue anyway? (y/n): ").strip().lower() != "y": sys.exit(0) # Confirm sys.stdout.write(f"\n📦 Preparing {RELEASE_KINDS[kind]} release: v{version}\n") create_release(version, kind, dry_run=args.dry_run) if __name__ == "__main__": main()