Files
jbpenrath 04faf870f6 🔧(release) regenerate uv.lock files when bumping version
The release script was not updating uv.lock files after bumping
the version in pyproject.toml, causing lockfile desync.
2026-03-05 16:38:00 +01:00

308 lines
11 KiB
Python
Executable File

#!/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()