mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
The release script was not updating uv.lock files after bumping the version in pyproject.toml, causing lockfile desync.
308 lines
11 KiB
Python
Executable File
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()
|