mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-04-03 09:38:15 +00:00
After PyPI publish, clones crewAIInc/crew_deployment_test, bumps the crewai[tools] pin to the new version, regenerates uv.lock, and pushes to main. Includes retry logic for CDN propagation delays.
1884 lines
61 KiB
Python
1884 lines
61 KiB
Python
"""Development tools for version bumping and git automation."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import Final, Literal
|
|
from urllib.request import urlopen
|
|
|
|
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.docs_check import docs_check
|
|
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_version(file_path: Path, new_version: str) -> bool:
|
|
"""Update the [project] version field in a pyproject.toml file.
|
|
|
|
Args:
|
|
file_path: Path to pyproject.toml 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()
|
|
new_content = re.sub(
|
|
r'^(version\s*=\s*")[^"]+(")',
|
|
rf"\g<1>{new_version}\2",
|
|
content,
|
|
count=1,
|
|
flags=re.MULTILINE,
|
|
)
|
|
if new_content != content:
|
|
file_path.write_text(new_content)
|
|
return True
|
|
return False
|
|
|
|
|
|
_DEFAULT_WORKSPACE_PACKAGES: Final[list[str]] = [
|
|
"crewai",
|
|
"crewai-tools",
|
|
"crewai-devtools",
|
|
]
|
|
|
|
|
|
def update_pyproject_dependencies(
|
|
file_path: Path,
|
|
new_version: str,
|
|
extra_packages: list[str] | None = None,
|
|
) -> bool:
|
|
"""Update workspace dependency versions in pyproject.toml.
|
|
|
|
Args:
|
|
file_path: Path to pyproject.toml file.
|
|
new_version: New version string.
|
|
extra_packages: Additional package names to update beyond the defaults.
|
|
|
|
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 = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or [])
|
|
|
|
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
|
|
|
|
|
|
ChangelogLang = Literal["en", "pt-BR", "ko", "ar"]
|
|
|
|
_PT_BR_MONTHS: Final[dict[int, str]] = {
|
|
1: "jan",
|
|
2: "fev",
|
|
3: "mar",
|
|
4: "abr",
|
|
5: "mai",
|
|
6: "jun",
|
|
7: "jul",
|
|
8: "ago",
|
|
9: "set",
|
|
10: "out",
|
|
11: "nov",
|
|
12: "dez",
|
|
}
|
|
|
|
_AR_MONTHS: Final[dict[int, str]] = {
|
|
1: "يناير",
|
|
2: "فبراير",
|
|
3: "مارس",
|
|
4: "أبريل",
|
|
5: "مايو",
|
|
6: "يونيو",
|
|
7: "يوليو",
|
|
8: "أغسطس",
|
|
9: "سبتمبر",
|
|
10: "أكتوبر",
|
|
11: "نوفمبر",
|
|
12: "ديسمبر",
|
|
}
|
|
|
|
_CHANGELOG_LOCALES: Final[
|
|
dict[ChangelogLang, dict[Literal["link_text", "language_name"], 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",
|
|
},
|
|
"ar": {
|
|
"link_text": "عرض الإصدار على GitHub",
|
|
"language_name": "Modern Standard Arabic",
|
|
},
|
|
}
|
|
|
|
|
|
def translate_release_notes(
|
|
release_notes: str,
|
|
lang: ChangelogLang,
|
|
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: ChangelogLang) -> 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}"
|
|
if lang == "ar":
|
|
return f"{now.day} {_AR_MONTHS[now.month]} {now.year}"
|
|
return now.strftime("%b %d, %Y")
|
|
|
|
|
|
def update_changelog(
|
|
changelog_path: Path,
|
|
version: str,
|
|
release_notes: str,
|
|
lang: ChangelogLang = "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 _pin_crewai_deps(content: str, version: str) -> str:
|
|
"""Replace crewai dependency version pins in a pyproject.toml string.
|
|
|
|
Handles both pinned (==) and minimum (>=) version specifiers,
|
|
as well as extras like [tools].
|
|
|
|
Args:
|
|
content: File content to transform.
|
|
version: New version string.
|
|
|
|
Returns:
|
|
Transformed content.
|
|
"""
|
|
return re.sub(
|
|
r'"crewai(\[tools\])?(==|>=)[^"]*"',
|
|
lambda m: f'"crewai{(m.group(1) or "")!s}=={version}"',
|
|
content,
|
|
)
|
|
|
|
|
|
def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]:
|
|
"""Update crewai dependency versions in CLI template pyproject.toml files.
|
|
|
|
Args:
|
|
templates_dir: Path to the CLI templates directory.
|
|
new_version: New version string.
|
|
|
|
Returns:
|
|
List of paths that were updated.
|
|
"""
|
|
updated = []
|
|
for pyproject in templates_dir.rglob("pyproject.toml"):
|
|
content = pyproject.read_text()
|
|
new_content = _pin_crewai_deps(content, new_version)
|
|
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
|
|
|
|
|
|
PrereleaseIndicator = Literal["a", "b", "rc", "alpha", "beta", "dev"]
|
|
_PRERELEASE_INDICATORS: Final[tuple[PrereleaseIndicator, ...]] = (
|
|
"a",
|
|
"b",
|
|
"rc",
|
|
"alpha",
|
|
"beta",
|
|
"dev",
|
|
)
|
|
|
|
|
|
def _is_prerelease(version: str) -> bool:
|
|
"""Check if a version string represents a pre-release."""
|
|
v = version.lower().lstrip("v")
|
|
return any(indicator in v for indicator in _PRERELEASE_INDICATORS)
|
|
|
|
|
|
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 not _is_prerelease(version):
|
|
prev_tags = [t for t in prev_tags if not _is_prerelease(t)]
|
|
|
|
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, repo: str | None = None
|
|
) -> None:
|
|
"""Poll a GitHub PR until it is merged. Exit if closed without merging.
|
|
|
|
Args:
|
|
branch_name: Branch name to look up the PR.
|
|
label: Human-readable label for status messages.
|
|
repo: Optional GitHub repo (owner/name) for cross-repo PRs.
|
|
"""
|
|
console.print(f"[cyan]Waiting for {label} to be merged...[/cyan]")
|
|
cmd = ["gh", "pr", "view", branch_name]
|
|
if repo:
|
|
cmd.extend(["--repo", repo])
|
|
cmd.extend(["--json", "state", "--jq", ".state"])
|
|
|
|
while True:
|
|
time.sleep(10)
|
|
try:
|
|
state = run_command(cmd)
|
|
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_output = run_command(
|
|
[
|
|
"git",
|
|
"log",
|
|
"--grep=^feat: bump versions to",
|
|
"--format=%H %s",
|
|
]
|
|
)
|
|
bump_entries = [
|
|
line for line in prev_bump_output.strip().split("\n") if line.strip()
|
|
]
|
|
|
|
is_stable = not _is_prerelease(version)
|
|
prev_commit = None
|
|
for entry in bump_entries[1:]:
|
|
bump_ver = entry.split("feat: bump versions to", 1)[-1].strip()
|
|
if is_stable and _is_prerelease(bump_ver):
|
|
continue
|
|
prev_commit = entry.split()[0]
|
|
break
|
|
|
|
if prev_commit:
|
|
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 = _is_prerelease(version)
|
|
|
|
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: list[ChangelogLang] = ["en", "pt-BR", "ko", "ar"]
|
|
|
|
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}")
|
|
|
|
|
|
_ENTERPRISE_REPO: Final[str | None] = os.getenv("ENTERPRISE_REPO")
|
|
_ENTERPRISE_VERSION_DIRS: Final[tuple[str, ...]] = tuple(
|
|
d.strip() for d in os.getenv("ENTERPRISE_VERSION_DIRS", "").split(",") if d.strip()
|
|
)
|
|
_ENTERPRISE_CREWAI_DEP_PATH: Final[str | None] = os.getenv("ENTERPRISE_CREWAI_DEP_PATH")
|
|
_ENTERPRISE_EXTRA_PACKAGES: Final[tuple[str, ...]] = tuple(
|
|
p.strip()
|
|
for p in os.getenv("ENTERPRISE_EXTRA_PACKAGES", "").split(",")
|
|
if p.strip()
|
|
)
|
|
|
|
|
|
def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool:
|
|
"""Update the crewai[tools] pin in an enterprise pyproject.toml.
|
|
|
|
Args:
|
|
pyproject_path: Path to the pyproject.toml file.
|
|
version: New crewai version string.
|
|
|
|
Returns:
|
|
True if the file was modified.
|
|
"""
|
|
if not pyproject_path.exists():
|
|
return False
|
|
|
|
content = pyproject_path.read_text()
|
|
new_content = _pin_crewai_deps(content, version)
|
|
if new_content != content:
|
|
pyproject_path.write_text(new_content)
|
|
return True
|
|
return False
|
|
|
|
|
|
_DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test"
|
|
|
|
_PYPI_POLL_INTERVAL: Final[int] = 15
|
|
_PYPI_POLL_TIMEOUT: Final[int] = 600
|
|
|
|
|
|
def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
|
|
"""Update the deployment test repo to pin the new crewai version.
|
|
|
|
Clones the repo, updates the crewai[tools] pin in pyproject.toml,
|
|
regenerates the lockfile, commits, and pushes directly to main.
|
|
|
|
Args:
|
|
version: New crewai version string.
|
|
is_prerelease: Whether this is a pre-release version.
|
|
"""
|
|
console.print(
|
|
f"\n[bold cyan]Updating {_DEPLOYMENT_TEST_REPO} to {version}[/bold cyan]"
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
repo_dir = Path(tmp) / "crew_deployment_test"
|
|
run_command(["gh", "repo", "clone", _DEPLOYMENT_TEST_REPO, str(repo_dir)])
|
|
console.print(f"[green]✓[/green] Cloned {_DEPLOYMENT_TEST_REPO}")
|
|
|
|
pyproject = repo_dir / "pyproject.toml"
|
|
content = pyproject.read_text()
|
|
new_content = re.sub(
|
|
r'"crewai\[tools\]==[^"]+"',
|
|
f'"crewai[tools]=={version}"',
|
|
content,
|
|
)
|
|
if new_content == content:
|
|
console.print(
|
|
"[yellow]Warning:[/yellow] No crewai[tools] pin found to update"
|
|
)
|
|
return
|
|
pyproject.write_text(new_content)
|
|
console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}")
|
|
|
|
lock_cmd = [
|
|
"uv",
|
|
"lock",
|
|
"--refresh-package",
|
|
"crewai",
|
|
"--refresh-package",
|
|
"crewai-tools",
|
|
]
|
|
if is_prerelease:
|
|
lock_cmd.append("--prerelease=allow")
|
|
|
|
max_retries = 10
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
run_command(lock_cmd, cwd=repo_dir)
|
|
break
|
|
except subprocess.CalledProcessError:
|
|
if attempt == max_retries:
|
|
console.print(
|
|
f"[red]Error:[/red] uv lock failed after {max_retries} attempts"
|
|
)
|
|
raise
|
|
console.print(
|
|
f"[yellow]uv lock failed (attempt {attempt}/{max_retries}),"
|
|
f" retrying in {_PYPI_POLL_INTERVAL}s...[/yellow]"
|
|
)
|
|
time.sleep(_PYPI_POLL_INTERVAL)
|
|
console.print("[green]✓[/green] Lockfile updated")
|
|
|
|
run_command(["git", "add", "pyproject.toml", "uv.lock"], cwd=repo_dir)
|
|
run_command(
|
|
["git", "commit", "-m", f"chore: bump crewai to {version}"],
|
|
cwd=repo_dir,
|
|
)
|
|
run_command(["git", "push"], cwd=repo_dir)
|
|
console.print(f"[green]✓[/green] Pushed to {_DEPLOYMENT_TEST_REPO}")
|
|
|
|
|
|
def _wait_for_pypi(package: str, version: str) -> None:
|
|
"""Poll PyPI until a specific package version is available.
|
|
|
|
Args:
|
|
package: PyPI package name.
|
|
version: Version string to wait for.
|
|
"""
|
|
url = f"https://pypi.org/pypi/{package}/{version}/json"
|
|
deadline = time.monotonic() + _PYPI_POLL_TIMEOUT
|
|
|
|
console.print(f"[cyan]Waiting for {package}=={version} to appear on PyPI...[/cyan]")
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with urlopen(url) as resp: # noqa: S310
|
|
if resp.status == 200:
|
|
console.print(
|
|
f"[green]✓[/green] {package}=={version} is available on PyPI"
|
|
)
|
|
return
|
|
except Exception: # noqa: S110
|
|
pass
|
|
time.sleep(_PYPI_POLL_INTERVAL)
|
|
|
|
console.print(
|
|
f"[red]Error:[/red] Timed out waiting for {package}=={version} on PyPI"
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None:
|
|
"""Clone the enterprise repo, bump versions, and create a release PR.
|
|
|
|
Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and
|
|
ENTERPRISE_CREWAI_DEP_PATH to be validated before calling.
|
|
|
|
Args:
|
|
version: New version string.
|
|
is_prerelease: Whether this is a pre-release version.
|
|
dry_run: Show what would be done without making changes.
|
|
"""
|
|
if (
|
|
not _ENTERPRISE_REPO
|
|
or not _ENTERPRISE_VERSION_DIRS
|
|
or not _ENTERPRISE_CREWAI_DEP_PATH
|
|
):
|
|
console.print("[red]Error:[/red] Enterprise env vars not configured")
|
|
sys.exit(1)
|
|
|
|
enterprise_repo: str = _ENTERPRISE_REPO
|
|
enterprise_dep_path: str = _ENTERPRISE_CREWAI_DEP_PATH
|
|
|
|
console.print(
|
|
f"\n[bold cyan]Phase 3: Releasing {enterprise_repo} {version}[/bold cyan]"
|
|
)
|
|
|
|
if dry_run:
|
|
console.print(f"[dim][DRY RUN][/dim] Would clone {enterprise_repo}")
|
|
for d in _ENTERPRISE_VERSION_DIRS:
|
|
console.print(f"[dim][DRY RUN][/dim] Would update versions in {d}")
|
|
console.print(
|
|
f"[dim][DRY RUN][/dim] Would update crewai[tools] dep in "
|
|
f"{enterprise_dep_path}"
|
|
)
|
|
console.print(
|
|
"[dim][DRY RUN][/dim] Would create bump PR, wait for merge, "
|
|
"then tag and release"
|
|
)
|
|
return
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
repo_dir = Path(tmp) / enterprise_repo.split("/")[-1]
|
|
console.print(f"Cloning {enterprise_repo}...")
|
|
run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)])
|
|
console.print(f"[green]✓[/green] Cloned {enterprise_repo}")
|
|
|
|
# --- bump versions ---
|
|
for rel_dir in _ENTERPRISE_VERSION_DIRS:
|
|
pkg_dir = repo_dir / rel_dir
|
|
if not pkg_dir.exists():
|
|
console.print(
|
|
f"[yellow]Warning:[/yellow] {rel_dir} not found, skipping"
|
|
)
|
|
continue
|
|
|
|
for vfile in find_version_files(pkg_dir):
|
|
if update_version_in_file(vfile, version):
|
|
console.print(
|
|
f"[green]✓[/green] Updated: {vfile.relative_to(repo_dir)}"
|
|
)
|
|
|
|
pyproject = pkg_dir / "pyproject.toml"
|
|
if pyproject.exists():
|
|
if update_pyproject_version(pyproject, version):
|
|
console.print(
|
|
f"[green]✓[/green] Updated version in: "
|
|
f"{pyproject.relative_to(repo_dir)}"
|
|
)
|
|
if update_pyproject_dependencies(
|
|
pyproject, version, extra_packages=list(_ENTERPRISE_EXTRA_PACKAGES)
|
|
):
|
|
console.print(
|
|
f"[green]✓[/green] Updated deps in: "
|
|
f"{pyproject.relative_to(repo_dir)}"
|
|
)
|
|
|
|
# --- update crewai[tools] pin ---
|
|
enterprise_pyproject = repo_dir / enterprise_dep_path
|
|
if _update_enterprise_crewai_dep(enterprise_pyproject, version):
|
|
console.print(
|
|
f"[green]✓[/green] Updated crewai[tools] dep in {enterprise_dep_path}"
|
|
)
|
|
|
|
_wait_for_pypi("crewai", version)
|
|
|
|
console.print("\nSyncing workspace...")
|
|
sync_cmd = [
|
|
"uv",
|
|
"sync",
|
|
"--refresh-package",
|
|
"crewai",
|
|
"--refresh-package",
|
|
"crewai-tools",
|
|
"--refresh-package",
|
|
"crewai-files",
|
|
]
|
|
if is_prerelease:
|
|
sync_cmd.append("--prerelease=allow")
|
|
|
|
max_retries = 10
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
run_command(sync_cmd, cwd=repo_dir)
|
|
break
|
|
except subprocess.CalledProcessError:
|
|
if attempt == max_retries:
|
|
console.print(
|
|
f"[red]Error:[/red] uv sync failed after {max_retries} attempts"
|
|
)
|
|
raise
|
|
console.print(
|
|
f"[yellow]uv sync failed (attempt {attempt}/{max_retries}),"
|
|
f" retrying in {_PYPI_POLL_INTERVAL}s...[/yellow]"
|
|
)
|
|
time.sleep(_PYPI_POLL_INTERVAL)
|
|
console.print("[green]✓[/green] Workspace synced")
|
|
|
|
# --- branch, commit, push, PR ---
|
|
branch_name = f"feat/bump-version-{version}"
|
|
run_command(["git", "checkout", "-b", branch_name], cwd=repo_dir)
|
|
run_command(["git", "add", "."], cwd=repo_dir)
|
|
run_command(
|
|
["git", "commit", "-m", f"feat: bump versions to {version}"],
|
|
cwd=repo_dir,
|
|
)
|
|
console.print("[green]✓[/green] Changes committed")
|
|
|
|
run_command(["git", "push", "-u", "origin", branch_name], cwd=repo_dir)
|
|
console.print("[green]✓[/green] Branch pushed")
|
|
|
|
pr_url = run_command(
|
|
[
|
|
"gh",
|
|
"pr",
|
|
"create",
|
|
"--repo",
|
|
enterprise_repo,
|
|
"--base",
|
|
"main",
|
|
"--title",
|
|
f"feat: bump versions to {version}",
|
|
"--body",
|
|
"",
|
|
],
|
|
cwd=repo_dir,
|
|
)
|
|
console.print("[green]✓[/green] Enterprise bump PR created")
|
|
console.print(f"[cyan]PR URL:[/cyan] {pr_url}")
|
|
|
|
_poll_pr_until_merged(branch_name, "enterprise bump PR", repo=enterprise_repo)
|
|
|
|
# --- tag and release ---
|
|
run_command(["git", "checkout", "main"], cwd=repo_dir)
|
|
run_command(["git", "pull"], cwd=repo_dir)
|
|
|
|
tag_name = version
|
|
run_command(
|
|
["git", "tag", "-a", tag_name, "-m", f"Release {version}"],
|
|
cwd=repo_dir,
|
|
)
|
|
run_command(["git", "push", "origin", tag_name], cwd=repo_dir)
|
|
console.print(f"[green]✓[/green] Pushed tag {tag_name}")
|
|
|
|
gh_cmd = [
|
|
"gh",
|
|
"release",
|
|
"create",
|
|
tag_name,
|
|
"--repo",
|
|
enterprise_repo,
|
|
"--title",
|
|
tag_name,
|
|
"--notes",
|
|
f"Release {version}",
|
|
]
|
|
if is_prerelease:
|
|
gh_cmd.append("--prerelease")
|
|
|
|
run_command(gh_cmd)
|
|
release_type = "prerelease" if is_prerelease else "release"
|
|
console.print(
|
|
f"[green]✓[/green] Created GitHub {release_type} for "
|
|
f"{enterprise_repo} {tag_name}"
|
|
)
|
|
|
|
|
|
def _trigger_pypi_publish(tag_name: str, wait: bool = False) -> None:
|
|
"""Trigger the PyPI publish GitHub Actions workflow.
|
|
|
|
Args:
|
|
tag_name: The release tag to publish.
|
|
wait: Block until the workflow run completes.
|
|
"""
|
|
# Capture the latest run ID before triggering so we can detect the new one
|
|
prev_run_id = ""
|
|
if wait:
|
|
try:
|
|
prev_run_id = run_command(
|
|
[
|
|
"gh",
|
|
"run",
|
|
"list",
|
|
"--workflow=publish.yml",
|
|
"--limit=1",
|
|
"--json=databaseId",
|
|
"--jq=.[0].databaseId",
|
|
]
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
console.print(
|
|
"[yellow]Note:[/yellow] Could not determine previous workflow run; "
|
|
"continuing without previous run ID"
|
|
)
|
|
|
|
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")
|
|
|
|
if wait:
|
|
console.print("[cyan]Waiting for PyPI publish workflow to complete...[/cyan]")
|
|
run_id = ""
|
|
deadline = time.monotonic() + 120
|
|
while time.monotonic() < deadline:
|
|
time.sleep(5)
|
|
try:
|
|
run_id = run_command(
|
|
[
|
|
"gh",
|
|
"run",
|
|
"list",
|
|
"--workflow=publish.yml",
|
|
"--limit=1",
|
|
"--json=databaseId",
|
|
"--jq=.[0].databaseId",
|
|
]
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
if run_id and run_id != prev_run_id:
|
|
break
|
|
|
|
if not run_id or run_id == prev_run_id:
|
|
console.print(
|
|
"[red]Error:[/red] Could not find the PyPI publish workflow run"
|
|
)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
run_command(["gh", "run", "watch", run_id, "--exit-status"])
|
|
except subprocess.CalledProcessError as e:
|
|
console.print(f"[red]✗[/red] PyPI publish workflow failed: {e}")
|
|
sys.exit(1)
|
|
console.print("[green]✓[/green] PyPI publish workflow completed")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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).
|
|
"""
|
|
console.print(
|
|
f"\n[yellow]Note:[/yellow] [bold]devtools bump[/bold] only bumps versions "
|
|
f"in this repo. It will not tag, publish to PyPI, or release enterprise.\n"
|
|
f"If you want a full end-to-end release, run "
|
|
f"[bold]devtools release {version}[/bold] instead."
|
|
)
|
|
if not Confirm.ask("Continue with bump only?", default=True):
|
|
sys.exit(0)
|
|
|
|
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.
|
|
"""
|
|
console.print(
|
|
"\n[yellow]Note:[/yellow] [bold]devtools tag[/bold] only tags and creates "
|
|
"a GitHub release for this repo. It will not bump versions, publish to "
|
|
"PyPI, or release enterprise.\n"
|
|
"If you want a full end-to-end release, run "
|
|
"[bold]devtools release <version>[/bold] instead."
|
|
)
|
|
if not Confirm.ask("Continue with tag only?", default=True):
|
|
sys.exit(0)
|
|
|
|
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")
|
|
@click.option(
|
|
"--skip-enterprise",
|
|
is_flag=True,
|
|
help="Skip the enterprise release phase",
|
|
)
|
|
@click.option(
|
|
"--skip-to-enterprise",
|
|
is_flag=True,
|
|
help="Skip phases 1 & 2, run only the enterprise release phase",
|
|
)
|
|
def release(
|
|
version: str,
|
|
dry_run: bool,
|
|
no_edit: bool,
|
|
skip_enterprise: bool,
|
|
skip_to_enterprise: 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. Then bumps versions and
|
|
releases the enterprise repo.
|
|
|
|
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.
|
|
skip_enterprise: Skip the enterprise release phase.
|
|
skip_to_enterprise: Skip phases 1 & 2, run only the enterprise release phase.
|
|
"""
|
|
try:
|
|
check_gh_installed()
|
|
|
|
if skip_enterprise and skip_to_enterprise:
|
|
console.print(
|
|
"[red]Error:[/red] Cannot use both --skip-enterprise "
|
|
"and --skip-to-enterprise"
|
|
)
|
|
sys.exit(1)
|
|
|
|
if not skip_enterprise or skip_to_enterprise:
|
|
missing: list[str] = []
|
|
if not _ENTERPRISE_REPO:
|
|
missing.append("ENTERPRISE_REPO")
|
|
if not _ENTERPRISE_VERSION_DIRS:
|
|
missing.append("ENTERPRISE_VERSION_DIRS")
|
|
if not _ENTERPRISE_CREWAI_DEP_PATH:
|
|
missing.append("ENTERPRISE_CREWAI_DEP_PATH")
|
|
if missing:
|
|
console.print(
|
|
f"[red]Error:[/red] Missing required environment variable(s): "
|
|
f"{', '.join(missing)}\n"
|
|
f"Set them or pass --skip-enterprise to skip the enterprise release."
|
|
)
|
|
sys.exit(1)
|
|
|
|
cwd = Path.cwd()
|
|
lib_dir = cwd / "lib"
|
|
|
|
is_prerelease = _is_prerelease(version)
|
|
|
|
if skip_to_enterprise:
|
|
_release_enterprise(version, is_prerelease, dry_run)
|
|
console.print(
|
|
f"\n[green]✓[/green] Enterprise release [bold]{version}[/bold] complete!"
|
|
)
|
|
return
|
|
|
|
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, wait=True)
|
|
_update_deployment_test_repo(version, is_prerelease)
|
|
|
|
if not skip_enterprise:
|
|
_release_enterprise(version, is_prerelease, dry_run)
|
|
|
|
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)
|
|
cli.add_command(docs_check)
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point for the CLI."""
|
|
cli()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|