Files
crewAI/lib/devtools/src/crewai_devtools/cli.py
Greyson LaLonde 326ec15d54 feat(devtools): add release command and trigger PyPI publish
* feat(devtools): add release command and fix automerge on protected branches

Replace gh pr merge --auto with polling-based merge wait that prints the
PR URL for manual review. Add unified release command that chains bump
and tag into a single end-to-end workflow.

* feat(devtools): trigger PyPI publish workflow after GitHub release

* refactor(devtools): extract shared helpers to eliminate duplication

Extract _poll_pr_until_merged, _update_all_versions,
_generate_release_notes, _update_docs_and_create_pr,
_create_tag_and_release, and _trigger_pypi_publish into reusable
helpers. All three commands (bump, tag, release) now compose from
these shared functions.
2026-03-13 16:41:27 -04:00

1314 lines
42 KiB
Python

"""Development tools for version bumping and git automation."""
import os
from pathlib import Path
import subprocess
import sys
import time
import click
from dotenv import load_dotenv
from github import Github
from openai import OpenAI
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Confirm
from crewai_devtools.prompts import RELEASE_NOTES_PROMPT, TRANSLATE_RELEASE_NOTES_PROMPT
load_dotenv()
console = Console()
def run_command(cmd: list[str], cwd: Path | None = None) -> str:
"""Run a shell command and return output.
Args:
cmd: Command to run as list of strings.
cwd: Working directory for command.
Returns:
Command output as string.
Raises:
subprocess.CalledProcessError: If command fails.
"""
result = subprocess.run( # noqa: S603
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def check_gh_installed() -> None:
"""Check if GitHub CLI is installed and offer to install it.
Raises:
SystemExit: If gh is not installed and user declines installation.
"""
try:
run_command(["gh", "--version"])
except (subprocess.CalledProcessError, FileNotFoundError):
console.print("[yellow]Warning:[/yellow] GitHub CLI (gh) is not installed")
import platform
if platform.system() == "Darwin":
try:
run_command(["brew", "--version"])
from rich.prompt import Confirm
if Confirm.ask(
"\n[bold]Would you like to install GitHub CLI via Homebrew?[/bold]",
default=True,
):
try:
console.print("\nInstalling GitHub CLI...")
subprocess.run(
["brew", "install", "gh"], # noqa: S607
check=True,
)
console.print(
"[green]✓[/green] GitHub CLI installed successfully"
)
console.print("\nAuthenticating with GitHub...")
subprocess.run(
["gh", "auth", "login"], # noqa: S607
check=True,
)
console.print("[green]✓[/green] GitHub authentication complete")
return
except subprocess.CalledProcessError as e:
console.print(
f"[red]Error:[/red] Failed to install or authenticate gh: {e}"
)
console.print(
"\nYou can try running [bold]gh auth login[/bold] manually"
)
except (subprocess.CalledProcessError, FileNotFoundError):
pass
console.print("\nPlease install GitHub CLI from: https://cli.github.com/")
console.print("\nInstallation instructions:")
console.print(" macOS: brew install gh")
console.print(
" Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md"
)
console.print(" Windows: winget install --id GitHub.cli")
sys.exit(1)
def check_git_clean() -> None:
"""Check if git working directory is clean.
Raises:
SystemExit: If there are uncommitted changes.
"""
try:
status = run_command(["git", "status", "--porcelain"])
if status:
console.print(
"[red]Error:[/red] You have uncommitted changes. Please commit or stash them first."
)
sys.exit(1)
except subprocess.CalledProcessError as e:
console.print(f"[red]Error checking git status:[/red] {e}")
sys.exit(1)
def update_version_in_file(file_path: Path, new_version: str) -> bool:
"""Update __version__ attribute in a Python file.
Args:
file_path: Path to Python file.
new_version: New version string.
Returns:
True if version was updated, False otherwise.
"""
if not file_path.exists():
return False
content = file_path.read_text()
lines = content.splitlines()
updated = False
for i, line in enumerate(lines):
if line.strip().startswith("__version__"):
lines[i] = f'__version__ = "{new_version}"'
updated = True
break
if updated:
file_path.write_text("\n".join(lines) + "\n")
return True
return False
def update_pyproject_dependencies(file_path: Path, new_version: str) -> bool:
"""Update workspace dependency versions in pyproject.toml.
Args:
file_path: Path to pyproject.toml file.
new_version: New version string.
Returns:
True if any dependencies were updated, False otherwise.
"""
if not file_path.exists():
return False
content = file_path.read_text()
lines = content.splitlines()
updated = False
workspace_packages = ["crewai", "crewai-tools", "crewai-devtools"]
for i, line in enumerate(lines):
for pkg in workspace_packages:
if f"{pkg}==" in line:
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
if '"' in line:
lines[i] = f'{indent}"{pkg}=={new_version}",'
elif "'" in line:
lines[i] = f"{indent}'{pkg}=={new_version}',"
else:
lines[i] = f"{indent}{pkg}=={new_version},"
updated = True
if updated:
file_path.write_text("\n".join(lines) + "\n")
return True
return False
def add_docs_version(docs_json_path: Path, version: str) -> bool:
"""Add a new version to the Mintlify docs.json versioning config.
Copies the current default version's tabs into a new version entry,
sets the new version as default, and marks the previous default as
non-default. Operates on all languages.
Args:
docs_json_path: Path to docs/docs.json.
version: Version string (e.g., "1.10.1b1").
Returns:
True if docs.json was updated, False otherwise.
"""
import json
if not docs_json_path.exists():
return False
data = json.loads(docs_json_path.read_text())
version_label = f"v{version}"
updated = False
for lang in data.get("navigation", {}).get("languages", []):
versions = lang.get("versions", [])
if not versions:
continue
# Skip if this version already exists for this language
if any(v.get("version") == version_label for v in versions):
continue
# Find the current default and copy its tabs
default_version = next(
(v for v in versions if v.get("default")),
versions[0],
)
new_version = {
"version": version_label,
"default": True,
"tabs": default_version.get("tabs", []),
}
# Remove default flag from old default
default_version.pop("default", None)
# Insert new version at the beginning
versions.insert(0, new_version)
updated = True
if not updated:
return False
docs_json_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
return True
_PT_BR_MONTHS = {
1: "jan",
2: "fev",
3: "mar",
4: "abr",
5: "mai",
6: "jun",
7: "jul",
8: "ago",
9: "set",
10: "out",
11: "nov",
12: "dez",
}
_CHANGELOG_LOCALES: dict[str, dict[str, str]] = {
"en": {
"link_text": "View release on GitHub",
"language_name": "English",
},
"pt-BR": {
"link_text": "Ver release no GitHub",
"language_name": "Brazilian Portuguese",
},
"ko": {
"link_text": "GitHub 릴리스 보기",
"language_name": "Korean",
},
}
def translate_release_notes(
release_notes: str,
lang: str,
client: OpenAI,
) -> str:
"""Translate release notes into the target language using OpenAI.
Args:
release_notes: English release notes markdown.
lang: Language code (e.g., "pt-BR", "ko").
client: OpenAI client instance.
Returns:
Translated release notes, or original on failure.
"""
locale_cfg = _CHANGELOG_LOCALES.get(lang)
if not locale_cfg:
return release_notes
language_name = locale_cfg["language_name"]
prompt = TRANSLATE_RELEASE_NOTES_PROMPT.substitute(
language=language_name,
release_notes=release_notes,
)
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": f"You are a professional translator. Translate technical documentation into {language_name}.",
},
{"role": "user", "content": prompt},
],
temperature=0.3,
)
return response.choices[0].message.content or release_notes
except Exception as e:
console.print(
f"[yellow]Warning:[/yellow] Could not translate to {language_name}: {e}"
)
return release_notes
def _format_changelog_date(lang: str) -> str:
"""Format today's date for a changelog entry in the given language."""
from datetime import datetime
now = datetime.now()
if lang == "ko":
return f"{now.year}{now.month}{now.day}"
if lang == "pt-BR":
return f"{now.day:02d} {_PT_BR_MONTHS[now.month]} {now.year}"
return now.strftime("%b %d, %Y")
def update_changelog(
changelog_path: Path,
version: str,
release_notes: str,
lang: str = "en",
) -> bool:
"""Prepend a new release entry to a docs changelog file.
Args:
changelog_path: Path to the changelog.mdx file.
version: Version string (e.g., "1.9.3").
release_notes: Markdown release notes content.
lang: Language code for localized date/link text.
Returns:
True if changelog was updated, False otherwise.
"""
if not changelog_path.exists():
return False
locale_cfg = _CHANGELOG_LOCALES.get(lang, _CHANGELOG_LOCALES["en"])
date_label = _format_changelog_date(lang)
link_text = locale_cfg["link_text"]
# Indent each non-empty line with 2 spaces to match <Update> block format
indented_lines = []
for line in release_notes.splitlines():
if line.strip():
indented_lines.append(f" {line}")
else:
indented_lines.append("")
indented_notes = "\n".join(indented_lines)
entry = (
f'<Update label="{date_label}">\n'
f" ## v{version}\n"
f"\n"
f" [{link_text}]"
f"(https://github.com/crewAIInc/crewAI/releases/tag/{version})\n"
f"\n"
f"{indented_notes}\n"
f"\n"
f"</Update>"
)
content = changelog_path.read_text()
# Insert after the frontmatter closing ---
parts = content.split("---", 2)
if len(parts) >= 3:
new_content = (
parts[0]
+ "---"
+ parts[1]
+ "---\n"
+ entry
+ "\n\n"
+ parts[2].lstrip("\n")
)
else:
new_content = entry + "\n\n" + content
changelog_path.write_text(new_content)
return True
def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]:
"""Update crewai dependency versions in CLI template pyproject.toml files.
Handles both pinned (==) and minimum (>=) version specifiers,
as well as extras like [tools].
Args:
templates_dir: Path to the CLI templates directory.
new_version: New version string.
Returns:
List of paths that were updated.
"""
import re
updated = []
for pyproject in templates_dir.rglob("pyproject.toml"):
content = pyproject.read_text()
new_content = re.sub(
r'"crewai(\[tools\])?(==|>=)[^"]*"',
lambda m: f'"crewai{(m.group(1) or "")!s}=={new_version}"',
content,
)
if new_content != content:
pyproject.write_text(new_content)
updated.append(pyproject)
return updated
def find_version_files(base_path: Path) -> list[Path]:
"""Find all __init__.py files that contain __version__.
Args:
base_path: Base directory to search in.
Returns:
List of paths to files containing __version__.
"""
return [
init_file
for init_file in base_path.rglob("__init__.py")
if "__version__" in init_file.read_text()
]
def get_packages(lib_dir: Path) -> list[Path]:
"""Get all packages from lib/ directory.
Args:
lib_dir: Path to lib/ directory.
Returns:
List of package directory paths.
Raises:
SystemExit: If lib/ doesn't exist or no packages found.
"""
if not lib_dir.exists():
console.print("[red]Error:[/red] lib/ directory not found")
sys.exit(1)
packages = [p for p in lib_dir.iterdir() if p.is_dir()]
if not packages:
console.print("[red]Error:[/red] No packages found in lib/")
sys.exit(1)
return packages
def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
"""Get commits from the last tag, excluding current version.
Args:
tag_name: Current tag name (e.g., "v1.0.0").
version: Current version (e.g., "1.0.0").
Returns:
Tuple of (commit_range, commits) where commits is newline-separated.
"""
try:
all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n")
prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"]
if prev_tags:
last_tag = prev_tags[0]
commit_range = f"{last_tag}..HEAD"
commits = run_command(["git", "log", commit_range, "--pretty=format:%s"])
else:
commit_range = "HEAD"
commits = run_command(["git", "log", "--pretty=format:%s"])
except subprocess.CalledProcessError:
commit_range = "HEAD"
commits = run_command(["git", "log", "--pretty=format:%s"])
return commit_range, commits
def get_github_contributors(commit_range: str) -> list[str]:
"""Get GitHub usernames from commit range using GitHub API.
Args:
commit_range: Git commit range (e.g., "abc123..HEAD").
Returns:
List of GitHub usernames sorted alphabetically.
"""
try:
# Get GitHub token from gh CLI
try:
gh_token = run_command(["gh", "auth", "token"])
except subprocess.CalledProcessError:
gh_token = None
g = Github(login_or_token=gh_token) if gh_token else Github()
github_repo = g.get_repo("crewAIInc/crewAI")
commit_shas = run_command(
["git", "log", commit_range, "--pretty=format:%H"]
).split("\n")
contributors = set()
for sha in commit_shas:
if not sha:
continue
try:
commit = github_repo.get_commit(sha)
if commit.author and commit.author.login:
contributors.add(commit.author.login)
if commit.commit.message:
for line in commit.commit.message.split("\n"):
if line.strip().startswith("Co-authored-by:"):
if "<" in line and ">" in line:
email_part = line.split("<")[1].split(">")[0]
if "@users.noreply.github.com" in email_part:
username = email_part.split("+")[-1].split("@")[0]
contributors.add(username)
except Exception: # noqa: S112
continue
return sorted(list(contributors))
except Exception as e:
console.print(
f"[yellow]Warning:[/yellow] Could not fetch GitHub contributors: {e}"
)
return []
# ---------------------------------------------------------------------------
# Shared workflow helpers
# ---------------------------------------------------------------------------
def _poll_pr_until_merged(branch_name: str, label: str) -> None:
"""Poll a GitHub PR until it is merged. Exit if closed without merging."""
console.print(f"[cyan]Waiting for {label} to be merged...[/cyan]")
while True:
time.sleep(10)
try:
state = run_command(
[
"gh",
"pr",
"view",
branch_name,
"--json",
"state",
"--jq",
".state",
]
)
except subprocess.CalledProcessError:
state = ""
if state == "MERGED":
break
if state == "CLOSED":
console.print(f"[red]✗[/red] {label} was closed without merging")
sys.exit(1)
console.print(f"[dim]Still waiting for {label} to merge...[/dim]")
console.print(f"[green]✓[/green] {label} merged")
def _update_all_versions(
cwd: Path,
lib_dir: Path,
version: str,
packages: list[Path],
dry_run: bool,
) -> list[Path]:
"""Bump __version__, pyproject deps, template deps, and run uv sync."""
updated_files: list[Path] = []
for pkg in packages:
version_files = find_version_files(pkg)
for vfile in version_files:
if dry_run:
console.print(
f"[dim][DRY RUN][/dim] Would update: {vfile.relative_to(cwd)}"
)
else:
if update_version_in_file(vfile, version):
console.print(f"[green]✓[/green] Updated: {vfile.relative_to(cwd)}")
updated_files.append(vfile)
else:
console.print(
f"[red]✗[/red] Failed to update: {vfile.relative_to(cwd)}"
)
pyproject = pkg / "pyproject.toml"
if pyproject.exists():
if dry_run:
console.print(
f"[dim][DRY RUN][/dim] Would update dependencies in: {pyproject.relative_to(cwd)}"
)
else:
if update_pyproject_dependencies(pyproject, version):
console.print(
f"[green]✓[/green] Updated dependencies in: {pyproject.relative_to(cwd)}"
)
updated_files.append(pyproject)
if not updated_files and not dry_run:
console.print(
"[yellow]Warning:[/yellow] No __version__ attributes found to update"
)
# Update CLI template pyproject.toml files
templates_dir = lib_dir / "crewai" / "src" / "crewai" / "cli" / "templates"
if templates_dir.exists():
if dry_run:
for tpl in templates_dir.rglob("pyproject.toml"):
console.print(
f"[dim][DRY RUN][/dim] Would update template: {tpl.relative_to(cwd)}"
)
else:
tpl_updated = update_template_dependencies(templates_dir, version)
for tpl in tpl_updated:
console.print(
f"[green]✓[/green] Updated template: {tpl.relative_to(cwd)}"
)
updated_files.append(tpl)
if not dry_run:
console.print("\nSyncing workspace...")
run_command(["uv", "sync"])
console.print("[green]✓[/green] Workspace synced")
else:
console.print("[dim][DRY RUN][/dim] Would run: uv sync")
return updated_files
def _generate_release_notes(
version: str,
tag_name: str,
no_edit: bool,
) -> tuple[str, OpenAI, bool]:
"""Generate, display, and optionally edit release notes.
Returns:
Tuple of (release_notes, openai_client, is_prerelease).
"""
release_notes = f"Release {version}"
commits = ""
with console.status("[cyan]Generating release notes..."):
try:
prev_bump_commit = run_command(
[
"git",
"log",
"--grep=^feat: bump versions to",
"--format=%H",
"-n",
"2",
]
)
commits_list = prev_bump_commit.strip().split("\n")
if len(commits_list) > 1:
prev_commit = commits_list[1]
commit_range = f"{prev_commit}..HEAD"
commits = run_command(
["git", "log", commit_range, "--pretty=format:%s"]
)
commit_lines = [
line
for line in commits.split("\n")
if not line.startswith("feat: bump versions to")
]
commits = "\n".join(commit_lines)
else:
commit_range, commits = get_commits_from_last_tag(tag_name, version)
except subprocess.CalledProcessError:
commit_range, commits = get_commits_from_last_tag(tag_name, version)
github_contributors = get_github_contributors(commit_range)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
if commits.strip():
contributors_section = ""
if github_contributors:
contributors_section = f"\n\n## Contributors\n\n{', '.join([f'@{u}' for u in github_contributors])}"
prompt = RELEASE_NOTES_PROMPT.substitute(
version=version,
commits=commits,
contributors_section=contributors_section,
)
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a helpful assistant that generates clear, concise release notes.",
},
{"role": "user", "content": prompt},
],
temperature=0.7,
)
release_notes = response.choices[0].message.content or f"Release {version}"
console.print("[green]✓[/green] Generated release notes")
if commits.strip():
try:
console.print()
md = Markdown(release_notes, justify="left")
console.print(
Panel(
md,
title="[bold cyan]Generated Release Notes[/bold cyan]",
border_style="cyan",
padding=(1, 2),
)
)
except Exception as e:
console.print(
f"[yellow]Warning:[/yellow] Could not render release notes: {e}"
)
console.print("Using default release notes")
if not no_edit:
if Confirm.ask(
"\n[bold]Would you like to edit the release notes?[/bold]", default=True
):
edited_notes = click.edit(release_notes)
if edited_notes is not None:
release_notes = edited_notes.strip()
console.print("\n[green]✓[/green] Release notes updated")
else:
console.print("\n[green]✓[/green] Using original release notes")
else:
console.print(
"\n[green]✓[/green] Using generated release notes without editing"
)
else:
console.print(
"\n[green]✓[/green] Using generated release notes without editing"
)
is_prerelease = any(
indicator in version.lower()
for indicator in ["a", "b", "rc", "alpha", "beta", "dev"]
)
return release_notes, openai_client, is_prerelease
def _update_docs_and_create_pr(
cwd: Path,
version: str,
release_notes: str,
openai_client: OpenAI,
is_prerelease: bool,
dry_run: bool,
) -> str | None:
"""Update changelogs and docs version switcher, create PR if needed.
Returns:
The docs branch name if a PR was created, None otherwise.
"""
docs_json_path = cwd / "docs" / "docs.json"
changelog_langs = ["en", "pt-BR", "ko"]
if not dry_run:
docs_files_staged: list[str] = []
for lang in changelog_langs:
cl_path = cwd / "docs" / lang / "changelog.mdx"
if lang == "en":
notes_for_lang = release_notes
else:
console.print(f"[dim]Translating release notes to {lang}...[/dim]")
notes_for_lang = translate_release_notes(
release_notes, lang, openai_client
)
if update_changelog(cl_path, version, notes_for_lang, lang=lang):
console.print(f"[green]✓[/green] Updated {cl_path.relative_to(cwd)}")
docs_files_staged.append(str(cl_path))
else:
console.print(
f"[yellow]Warning:[/yellow] Changelog not found at {cl_path.relative_to(cwd)}"
)
if not is_prerelease:
if add_docs_version(docs_json_path, version):
console.print(
f"[green]✓[/green] Added v{version} to docs version switcher"
)
docs_files_staged.append(str(docs_json_path))
else:
console.print(
f"[yellow]Warning:[/yellow] docs.json not found at {docs_json_path.relative_to(cwd)}"
)
if docs_files_staged:
docs_branch = f"docs/changelog-v{version}"
run_command(["git", "checkout", "-b", docs_branch])
for f in docs_files_staged:
run_command(["git", "add", f])
run_command(
[
"git",
"commit",
"-m",
f"docs: update changelog and version for v{version}",
]
)
console.print("[green]✓[/green] Committed docs updates")
run_command(["git", "push", "-u", "origin", docs_branch])
console.print(f"[green]✓[/green] Pushed branch {docs_branch}")
pr_url = run_command(
[
"gh",
"pr",
"create",
"--base",
"main",
"--title",
f"docs: update changelog and version for v{version}",
"--body",
"",
]
)
console.print("[green]✓[/green] Created docs PR")
console.print(f"[cyan]PR URL:[/cyan] {pr_url}")
return docs_branch
return None
for lang in changelog_langs:
cl_path = cwd / "docs" / lang / "changelog.mdx"
translated = " (translated)" if lang != "en" else ""
console.print(
f"[dim][DRY RUN][/dim] Would update {cl_path.relative_to(cwd)}{translated}"
)
if not is_prerelease:
console.print(
f"[dim][DRY RUN][/dim] Would add v{version} to docs version switcher"
)
else:
console.print("[dim][DRY RUN][/dim] Skipping docs version (pre-release)")
console.print(
f"[dim][DRY RUN][/dim] Would create branch docs/changelog-v{version}, PR, and wait for merge"
)
return None
def _create_tag_and_release(
tag_name: str,
release_notes: str,
is_prerelease: bool,
) -> None:
"""Create git tag, push it, and create a GitHub release."""
with console.status(f"[cyan]Creating tag {tag_name}..."):
try:
run_command(["git", "tag", "-a", tag_name, "-m", release_notes])
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Created tag {tag_name}: {e}")
sys.exit(1)
console.print(f"[green]✓[/green] Created tag {tag_name}")
with console.status(f"[cyan]Pushing tag {tag_name}..."):
try:
run_command(["git", "push", "origin", tag_name])
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Pushed tag {tag_name}: {e}")
sys.exit(1)
console.print(f"[green]✓[/green] Pushed tag {tag_name}")
with console.status("[cyan]Creating GitHub Release..."):
try:
gh_cmd = [
"gh",
"release",
"create",
tag_name,
"--title",
tag_name,
"--notes",
release_notes,
]
if is_prerelease:
gh_cmd.append("--prerelease")
run_command(gh_cmd)
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Created GitHub Release: {e}")
sys.exit(1)
release_type = "prerelease" if is_prerelease else "release"
console.print(f"[green]✓[/green] Created GitHub {release_type} for {tag_name}")
def _trigger_pypi_publish(tag_name: str) -> None:
"""Trigger the PyPI publish GitHub Actions workflow."""
with console.status("[cyan]Triggering PyPI publish workflow..."):
try:
run_command(
[
"gh",
"workflow",
"run",
"publish.yml",
"-f",
f"release_tag={tag_name}",
]
)
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Triggered PyPI publish workflow: {e}")
sys.exit(1)
console.print("[green]✓[/green] Triggered PyPI publish workflow")
# ---------------------------------------------------------------------------
# CLI commands
# ---------------------------------------------------------------------------
@click.group()
def cli() -> None:
"""Development tools for version bumping and git automation."""
@click.command()
@click.argument("version")
@click.option(
"--dry-run", is_flag=True, help="Show what would be done without making changes"
)
@click.option("--no-push", is_flag=True, help="Don't push changes to remote")
@click.option(
"--no-commit", is_flag=True, help="Don't commit changes (just update files)"
)
def bump(version: str, dry_run: bool, no_push: bool, no_commit: bool) -> None:
"""Bump version across all packages in lib/.
Args:
version: New version to set (e.g., 1.0.0, 1.0.0a1).
dry_run: Show what would be done without making changes.
no_push: Don't push changes to remote.
no_commit: Don't commit changes (just update files).
"""
try:
check_gh_installed()
cwd = Path.cwd()
lib_dir = cwd / "lib"
if not dry_run:
console.print("Checking git status...")
check_git_clean()
console.print("[green]✓[/green] Working directory is clean")
else:
console.print("[dim][DRY RUN][/dim] Would check git status")
packages = get_packages(lib_dir)
console.print(f"\nFound {len(packages)} package(s) to update:")
for pkg in packages:
console.print(f" - {pkg.name}")
console.print(f"\nUpdating version to {version}...")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
if no_commit:
console.print("\nSkipping git operations (--no-commit flag set)")
else:
branch_name = f"feat/bump-version-{version}"
if not dry_run:
console.print(f"\nCreating branch {branch_name}...")
run_command(["git", "checkout", "-b", branch_name])
console.print("[green]✓[/green] Branch created")
console.print("\nCommitting changes...")
run_command(["git", "add", "."])
run_command(
["git", "commit", "-m", f"feat: bump versions to {version}"]
)
console.print("[green]✓[/green] Changes committed")
if not no_push:
console.print("\nPushing branch...")
run_command(["git", "push", "-u", "origin", branch_name])
console.print("[green]✓[/green] Branch pushed")
else:
console.print(
f"[dim][DRY RUN][/dim] Would create branch: {branch_name}"
)
console.print(
f"[dim][DRY RUN][/dim] Would commit: feat: bump versions to {version}"
)
if not no_push:
console.print(
f"[dim][DRY RUN][/dim] Would push branch: {branch_name}"
)
if not dry_run and not no_push:
console.print("\nCreating pull request...")
run_command(
[
"gh",
"pr",
"create",
"--base",
"main",
"--title",
f"feat: bump versions to {version}",
"--body",
"",
]
)
console.print("[green]✓[/green] Pull request created")
elif dry_run:
console.print(
f"[dim][DRY RUN][/dim] Would create PR: feat: bump versions to {version}"
)
else:
console.print("\nSkipping PR creation (--no-push flag set)")
console.print(f"\n[green]✓[/green] Version bump to {version} complete!")
except subprocess.CalledProcessError as e:
console.print(f"[red]Error running command:[/red] {e}")
if e.stderr:
console.print(e.stderr)
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
@click.command()
@click.option(
"--dry-run", is_flag=True, help="Show what would be done without making changes"
)
@click.option("--no-edit", is_flag=True, help="Skip editing release notes")
def tag(dry_run: bool, no_edit: bool) -> None:
"""Create and push a version tag on main branch.
Run this after the version bump PR has been merged.
Automatically detects version from __version__ in packages.
Args:
dry_run: Show what would be done without making changes.
no_edit: Skip editing release notes.
"""
try:
cwd = Path.cwd()
lib_dir = cwd / "lib"
packages = get_packages(lib_dir)
with console.status("[cyan]Validating package versions..."):
versions = {}
for pkg in packages:
version_files = find_version_files(pkg)
for vfile in version_files:
content = vfile.read_text()
for line in content.splitlines():
if line.strip().startswith("__version__"):
ver = line.split("=")[1].strip().strip('"').strip("'")
versions[vfile.relative_to(cwd)] = ver
break
if not versions:
console.print(
"[red]✗[/red] Validated package versions: Could not find __version__ in any package"
)
sys.exit(1)
unique_versions = set(versions.values())
if len(unique_versions) > 1:
console.print(
"[red]✗[/red] Validated package versions: Version mismatch detected"
)
for file, ver in versions.items():
console.print(f" {file}: {ver}")
sys.exit(1)
version = unique_versions.pop()
console.print(f"[green]✓[/green] Validated packages @ [bold]{version}[/bold]")
tag_name = version
if not dry_run:
with console.status("[cyan]Checking out main branch..."):
try:
run_command(["git", "checkout", "main"])
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Checked out main branch: {e}")
sys.exit(1)
console.print("[green]✓[/green] On main branch")
with console.status("[cyan]Pulling latest changes..."):
try:
run_command(["git", "pull"])
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] Pulled latest changes: {e}")
sys.exit(1)
console.print("[green]✓[/green] main branch up to date")
release_notes, openai_client, is_prerelease = _generate_release_notes(
version, tag_name, no_edit
)
docs_branch = _update_docs_and_create_pr(
cwd, version, release_notes, openai_client, is_prerelease, dry_run
)
if docs_branch:
_poll_pr_until_merged(docs_branch, "docs PR")
run_command(["git", "checkout", "main"])
run_command(["git", "pull"])
console.print("[green]✓[/green] main branch updated with docs changes")
if not dry_run:
_create_tag_and_release(tag_name, release_notes, is_prerelease)
console.print(
f"\n[green]✓[/green] Packages @ [bold]{version}[/bold] tagged successfully!"
)
except subprocess.CalledProcessError as e:
console.print(f"[red]Error running command:[/red] {e}")
if e.stderr:
console.print(e.stderr)
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
@click.command()
@click.argument("version")
@click.option(
"--dry-run", is_flag=True, help="Show what would be done without making changes"
)
@click.option("--no-edit", is_flag=True, help="Skip editing release notes")
def release(version: str, dry_run: bool, no_edit: bool) -> None:
"""Full release: bump versions, tag, and publish a GitHub release.
Combines bump and tag into a single workflow. Creates a version bump PR,
waits for it to be merged, then generates release notes, updates docs,
creates the tag, and publishes a GitHub release.
Args:
version: New version to set (e.g., 1.0.0, 1.0.0a1).
dry_run: Show what would be done without making changes.
no_edit: Skip editing release notes.
"""
try:
check_gh_installed()
cwd = Path.cwd()
lib_dir = cwd / "lib"
if not dry_run:
console.print("Checking git status...")
check_git_clean()
console.print("[green]✓[/green] Working directory is clean")
else:
console.print("[dim][DRY RUN][/dim] Would check git status")
packages = get_packages(lib_dir)
console.print(f"\nFound {len(packages)} package(s) to update:")
for pkg in packages:
console.print(f" - {pkg.name}")
# --- Phase 1: Bump versions ---
console.print(
f"\n[bold cyan]Phase 1: Bumping versions to {version}[/bold cyan]"
)
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
branch_name = f"feat/bump-version-{version}"
if not dry_run:
console.print(f"\nCreating branch {branch_name}...")
run_command(["git", "checkout", "-b", branch_name])
console.print("[green]✓[/green] Branch created")
console.print("\nCommitting changes...")
run_command(["git", "add", "."])
run_command(["git", "commit", "-m", f"feat: bump versions to {version}"])
console.print("[green]✓[/green] Changes committed")
console.print("\nPushing branch...")
run_command(["git", "push", "-u", "origin", branch_name])
console.print("[green]✓[/green] Branch pushed")
console.print("\nCreating pull request...")
bump_pr_url = run_command(
[
"gh",
"pr",
"create",
"--base",
"main",
"--title",
f"feat: bump versions to {version}",
"--body",
"",
]
)
console.print("[green]✓[/green] Pull request created")
console.print(f"[cyan]PR URL:[/cyan] {bump_pr_url}")
_poll_pr_until_merged(branch_name, "bump PR")
else:
console.print(f"[dim][DRY RUN][/dim] Would create branch: {branch_name}")
console.print(
f"[dim][DRY RUN][/dim] Would commit: feat: bump versions to {version}"
)
console.print(
"[dim][DRY RUN][/dim] Would push branch, create PR, and wait for merge"
)
# --- Phase 2: Tag and release ---
console.print(
f"\n[bold cyan]Phase 2: Tagging and releasing {version}[/bold cyan]"
)
tag_name = version
if not dry_run:
with console.status("[cyan]Checking out main branch..."):
run_command(["git", "checkout", "main"])
console.print("[green]✓[/green] On main branch")
with console.status("[cyan]Pulling latest changes..."):
run_command(["git", "pull"])
console.print("[green]✓[/green] main branch up to date")
release_notes, openai_client, is_prerelease = _generate_release_notes(
version, tag_name, no_edit
)
docs_branch = _update_docs_and_create_pr(
cwd, version, release_notes, openai_client, is_prerelease, dry_run
)
if docs_branch:
_poll_pr_until_merged(docs_branch, "docs PR")
run_command(["git", "checkout", "main"])
run_command(["git", "pull"])
console.print("[green]✓[/green] main branch updated with docs changes")
if not dry_run:
_create_tag_and_release(tag_name, release_notes, is_prerelease)
_trigger_pypi_publish(tag_name)
console.print(f"\n[green]✓[/green] Release [bold]{version}[/bold] complete!")
except subprocess.CalledProcessError as e:
console.print(f"[red]Error running command:[/red] {e}")
if e.stderr:
console.print(e.stderr)
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
cli.add_command(bump)
cli.add_command(tag)
cli.add_command(release)
def main() -> None:
"""Entry point for the CLI."""
cli()
if __name__ == "__main__":
main()