#!/usr/bin/env -S uv run python from __future__ import annotations import argparse from pathlib import Path import re import subprocess import sys from typing import Any, cast import tomlkit from tomlkit.items import Array def run_git_command( *args: str, check: bool = True, capture_output: bool = False ) -> subprocess.CompletedProcess[str]: """Run a git command and return the result.""" result = subprocess.run( ["git"] + list(args), check=check, capture_output=capture_output, text=True ) return result def ensure_public_remote() -> None: result = run_git_command("remote", "-v", capture_output=True, check=False) remotes = result.stdout public_remote_url = "git@github.com:mistralai/mistral-vibe.git" if public_remote_url in remotes: print("Public remote already exists with correct URL") return print(f"Creating public remote: {public_remote_url}") run_git_command("remote", "add", "public", public_remote_url) print("Public remote created successfully") def switch_to_tag(version: str) -> None: tag = f"v{version}" print(f"Switching to tag {tag}...") result = run_git_command( "rev-parse", "--verify", tag, capture_output=True, check=False ) if result.returncode != 0: raise ValueError(f"Tag {tag} does not exist") run_git_command("switch", "--detach", tag) print(f"Successfully switched to tag {tag}") def get_version_from_pyproject() -> str: pyproject_path = Path("pyproject.toml") if not pyproject_path.exists(): raise FileNotFoundError("pyproject.toml not found in current directory") content = pyproject_path.read_text() version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE) if not version_match: raise ValueError("Version not found in pyproject.toml") return version_match.group(1) def get_latest_version() -> str: result = run_git_command("ls-remote", "--tags", "public", capture_output=True) remote_tags_output = ( result.stdout.strip().split("\n") if result.stdout.strip() else [] ) if not remote_tags_output: raise ValueError("No version tags found on public remote") versions: list[tuple[int, int, int, str]] = [] MIN_PARTS_IN_LS_REMOTE_LINE = 2 # hash and ref for line in remote_tags_output: parts = line.split() if len(parts) < MIN_PARTS_IN_LS_REMOTE_LINE: continue _hash, tag_ref = parts[0], parts[1] if not tag_ref.startswith("refs/tags/"): continue tag = tag_ref.replace("refs/tags/", "") match = re.match(r"^v(\d+\.\d+\.\d+)$", tag) if not match: continue tag_version = match.group(1) try: major, minor, patch = parse_version(tag_version) versions.append((major, minor, patch, tag_version)) except ValueError: continue if not versions: raise ValueError( "No valid version tags found on public remote (format: vX.Y.Z)" ) versions.sort() return max(versions)[3] def get_base_version_from_current_branch() -> str: result = run_git_command( "describe", "--tags", "--match", "v[0-9]*.[0-9]*.[0-9]*", "--exclude", "*-private", "--abbrev=0", "HEAD", capture_output=True, ) tag = result.stdout.strip() match = re.match(r"^v(\d+\.\d+\.\d+)$", tag) if not match: raise ValueError(f"Invalid base version tag found: {tag}") return match.group(1) def parse_version(version_str: str) -> tuple[int, int, int]: match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip()) if not match: raise ValueError(f"Invalid version format: {version_str}") return int(match.group(1)), int(match.group(2)), int(match.group(3)) def create_release_branch(version: str) -> None: branch_name = f"release/v{version}" print(f"Creating release branch: {branch_name}") result = run_git_command( "branch", "--list", branch_name, capture_output=True, check=False ) if result.stdout.strip(): print(f"Warning: Branch {branch_name} already exists", file=sys.stderr) response = input(f"Delete and recreate {branch_name}? (y/N): ") if response.lower() == "y": run_git_command("branch", "-D", branch_name) else: print("Aborting", file=sys.stderr) sys.exit(1) run_git_command("switch", "-c", branch_name) print(f"Created and switched to branch {branch_name}") def cherry_pick_commits(previous_private_tag: str, current_private_tag: str) -> None: result = run_git_command( "rev-parse", "--verify", previous_private_tag, capture_output=True, check=False ) if result.returncode != 0: raise ValueError(f"Tag {previous_private_tag} does not exist") result = run_git_command( "rev-parse", "--verify", current_private_tag, capture_output=True, check=False ) if result.returncode != 0: raise ValueError(f"Tag {current_private_tag} does not exist") print( f"Cherry-picking commits from {previous_private_tag}..{current_private_tag}..." ) run_git_command("cherry-pick", f"{previous_private_tag}..{current_private_tag}") print("Successfully cherry-picked all commits") def squash_commits( previous_version: str, current_version: str, previous_private_tag: str, current_private_tag: str, ) -> None: print("Squashing commits into a single release commit...") run_git_command("reset", "--soft", f"v{previous_version}") # Get all contributors between previous and current private tags result = run_git_command( "log", f"{previous_private_tag}..{current_private_tag}", "--format=%aN <%aE>", capture_output=True, ) contributors = result.stdout.strip().split("\n") # Get current user current_user_result = run_git_command("config", "user.email", capture_output=True) current_user_email = current_user_result.stdout.strip() # Filter out current user and create co-authored lines vibe_marker = "vibe@mistral.ai" unique_coauthors = { f"Co-authored-by: {contributor}" for contributor in contributors if contributor and current_user_email not in contributor and vibe_marker not in contributor } # Add Mistral Vibe as co-author coauthored_lines = sorted(unique_coauthors) + [ "Co-authored-by: Mistral Vibe " ] # Create commit message commit_message = f"v{current_version}\n" for line in coauthored_lines: commit_message += f"\n{line}" # Create the commit run_git_command("commit", "-m", commit_message) print("Successfully created release commit with co-authors") def get_pinned_dependencies(group: str | None = None) -> list[str]: cmd = [ "uv", "export", "--no-hashes", "--no-emit-project", "--frozen", "--format", "requirements.txt", "--no-annotate", "--no-header", ] if group is None: cmd.append("--no-dev") else: cmd += ["--only-group", group] result = subprocess.run(cmd, check=True, capture_output=True, text=True) pins: list[str] = [] for raw_line in result.stdout.splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue pins.append(line) if not pins: target = group or "[project].dependencies" raise ValueError(f"uv export returned no dependencies for {target}") return pins def make_multiline_array(pins: list[str]) -> Array: arr = tomlkit.array() arr.extend(pins) arr.multiline(True) return arr def pin_dependencies(version: str) -> None: print("Pinning dependencies for release...") pyproject_path = Path("pyproject.toml") if not pyproject_path.exists(): raise FileNotFoundError("pyproject.toml not found in current directory") project_pins = get_pinned_dependencies() build_pins = get_pinned_dependencies(group="build") doc = tomlkit.parse(pyproject_path.read_text()) cast(dict[str, Any], doc["project"])["dependencies"] = make_multiline_array( project_pins ) cast(dict[str, Any], doc["dependency-groups"])["build"] = make_multiline_array( build_pins ) pyproject_path.write_text(tomlkit.dumps(doc)) print( f"Pinned {len(project_pins)} project deps and " f"{len(build_pins)} build-group deps in pyproject.toml" ) print("Refreshing uv.lock...") subprocess.run(["uv", "lock"], check=True) run_git_command("add", "pyproject.toml", "uv.lock") run_git_command( "commit", "--allow-empty", "-m", f"chore: pin dependencies for v{version}" ) print(f"Committed pinned dependencies for v{version}") def get_commits_summary(previous_version: str, current_version: str) -> str: previous_tag = f"v{previous_version}-private" current_tag = f"v{current_version}-private" result = run_git_command( "log", f"{previous_tag}..{current_tag}", "--oneline", capture_output=True ) return result.stdout.strip() def get_changelog_entry(version: str) -> str: changelog_path = Path("CHANGELOG.md") if not changelog_path.exists(): return "CHANGELOG.md not found" content = changelog_path.read_text() pattern = rf"^## \[{re.escape(version)}\] - .+?(?=^## \[|\Z)" match = re.search(pattern, content, re.MULTILINE | re.DOTALL) if not match: return f"No changelog entry found for version {version}" return match.group(0).strip() def print_summary( current_version: str, previous_version: str, commits_summary: str, changelog_entry: str, squash: bool, ) -> None: print("\n" + "=" * 80) print("RELEASE PREPARATION SUMMARY") print("=" * 80) print(f"\nVersion: {current_version}") print(f"Previous version: {previous_version}") print(f"Release branch: release/v{current_version}") print("\n" + "-" * 80) print("COMMITS IN THIS RELEASE") print("-" * 80) if commits_summary: print(commits_summary) else: print("No commits found") print("\n" + "-" * 80) print("CHANGELOG ENTRY") print("-" * 80) print(changelog_entry) print("\n" + "-" * 80) if not squash: print("NEXT STEPS") print("-" * 80) print( f"To review/edit commits before publishing, use interactive rebase:\n" f" git rebase -i v{previous_version}" ) print("\n" + "-" * 80) print("REMINDERS") print("-" * 80) print("Before publishing the release:") print(" ✓ Credit any public contributors in the release notes") print(" ✓ Close related issues once the release is published") print( " ✓ Review and update the changelog if needed " "(should be made in the private main branch)" ) print("\n" + "=" * 80) def main() -> None: parser = argparse.ArgumentParser( description="Prepare a release branch by cherry-picking from private tags", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)") parser.add_argument( "--no-squash", action="store_false", dest="squash", default=True, help="Disable squashing of commits into a single release commit", ) parser.add_argument( "--resume", action="store_true", help="Resume after cherry-picking commits" ) args = parser.parse_args() current_version = args.version squash = args.squash try: # Step 1: Ensure public remote exists ensure_public_remote() if args.resume: previous_version = get_base_version_from_current_branch() else: # Step 2: Fetch all remotes print("Fetching all remotes...") run_git_command("fetch", "--all") print("Successfully fetched all remotes") # Step 3: Find latest version previous_version = get_latest_version() print(f"Previous version: {previous_version}") # Step 4: Verify version matches pyproject.toml pyproject_version = get_version_from_pyproject() if current_version != pyproject_version: raise ValueError( f"Version mismatch: provided version '{current_version}' does not match " f"pyproject.toml version '{pyproject_version}'" ) print(f"Version verified: {current_version}") previous_private_tag = f"v{previous_version}-private" current_private_tag = f"v{current_version}-private" if not args.resume: # Step 5: Switch to previous version tag switch_to_tag(previous_version) # Step 6: Create release branch create_release_branch(current_version) # Step 7: Cherry-pick commits cherry_pick_commits(previous_private_tag, current_private_tag) # Step 8: Pin dependencies from uv.lock so the published wheel # has frozen Requires-Dist metadata. When squashing, this commit # is folded into the release commit; otherwise it stays as the # final commit on the release branch. pin_dependencies(current_version) # Step 9: Squash commits if squash: squash_commits( previous_version, current_version, previous_private_tag, current_private_tag, ) # Step 10: Get summary information commits_summary = get_commits_summary(previous_version, current_version) changelog_entry = get_changelog_entry(current_version) # Step 9: Print summary print_summary( current_version, previous_version, commits_summary, changelog_entry, squash ) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()