diff --git a/lib/devtools/README.md b/lib/devtools/README.md index 699c05593..e70721b52 100644 --- a/lib/devtools/README.md +++ b/lib/devtools/README.md @@ -8,18 +8,22 @@ Installed automatically via the workspace (`uv sync`). Requires: - [GitHub CLI](https://cli.github.com/) (`gh`) — authenticated - `OPENAI_API_KEY` env var — for release note generation and translation +- `ENTERPRISE_REPO` env var — GitHub repo for enterprise releases +- `ENTERPRISE_VERSION_DIRS` env var — comma-separated directories to bump in the enterprise repo +- `ENTERPRISE_CREWAI_DEP_PATH` env var — path to the pyproject.toml with the `crewai[tools]` pin in the enterprise repo ## Commands ### `devtools release ` -Full end-to-end release. Bumps versions, creates PRs, tags, and publishes a GitHub release. +Full end-to-end release. Bumps versions, creates PRs, tags, publishes a GitHub release, and releases the enterprise repo. ``` devtools release 1.10.3 -devtools release 1.10.3a1 # pre-release -devtools release 1.10.3 --no-edit # skip editing release notes -devtools release 1.10.3 --dry-run # preview without changes +devtools release 1.10.3a1 # pre-release +devtools release 1.10.3 --no-edit # skip editing release notes +devtools release 1.10.3 --dry-run # preview without changes +devtools release 1.10.3 --skip-enterprise # skip enterprise release phase ``` **Flow:** @@ -31,6 +35,10 @@ devtools release 1.10.3 --dry-run # preview without changes 5. Updates changelogs (en, pt-BR, ko) and docs version switcher 6. Creates docs PR against main, polls until merged 7. Tags main and creates GitHub release +8. Triggers PyPI publish workflow +9. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync` +10. Creates enterprise bump PR, polls until merged +11. Tags and creates GitHub release on enterprise repo ### `devtools bump ` diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index ca95a0e9c..6342ba9ec 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -2,10 +2,13 @@ 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 @@ -153,12 +156,24 @@ def update_version_in_file(file_path: Path, new_version: str) -> bool: return False -def update_pyproject_dependencies(file_path: Path, new_version: str) -> bool: +_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. @@ -170,7 +185,7 @@ def update_pyproject_dependencies(file_path: Path, new_version: str) -> bool: lines = content.splitlines() updated = False - workspace_packages = ["crewai", "crewai-tools", "crewai-devtools"] + workspace_packages = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or []) for i, line in enumerate(lines): for pkg in workspace_packages: @@ -431,12 +446,29 @@ def update_changelog( return True -def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]: - """Update crewai dependency versions in CLI template pyproject.toml files. +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. @@ -444,16 +476,10 @@ def update_template_dependencies(templates_dir: Path, new_version: str) -> list[ 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, - ) + new_content = _pin_crewai_deps(content, new_version) if new_content != content: pyproject.write_text(new_content) updated.append(pyproject) @@ -607,24 +633,26 @@ def get_github_contributors(commit_range: str) -> list[str]: # --------------------------------------------------------------------------- -def _poll_pr_until_merged(branch_name: str, label: str) -> None: - """Poll a GitHub PR until it is merged. Exit if closed without merging.""" +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( - [ - "gh", - "pr", - "view", - branch_name, - "--json", - "state", - "--jq", - ".state", - ] - ) + state = run_command(cmd) except subprocess.CalledProcessError: state = "" @@ -984,8 +1012,252 @@ def _create_tag_and_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.""" +_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 + + +_PYPI_POLL_INTERVAL: Final[int] = 15 +_PYPI_POLL_TIMEOUT: Final[int] = 600 + + +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_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...") + run_command(["uv", "sync"], cwd=repo_dir) + 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") + + 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") + + _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( @@ -1003,6 +1275,42 @@ def _trigger_pypi_publish(tag_name: str) -> None: 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 @@ -1032,6 +1340,15 @@ def bump(version: str, dry_run: bool, no_push: bool, no_commit: bool) -> None: 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() @@ -1136,6 +1453,16 @@ def tag(dry_run: bool, no_edit: bool) -> None: 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 [/bold] instead." + ) + if not Confirm.ask("Continue with tag only?", default=True): + sys.exit(0) + try: cwd = Path.cwd() lib_dir = cwd / "lib" @@ -1226,21 +1553,44 @@ def tag(dry_run: bool, no_edit: bool) -> None: "--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: +@click.option( + "--skip-enterprise", + is_flag=True, + help="Skip the enterprise release phase", +) +def release(version: str, dry_run: bool, no_edit: bool, skip_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. + 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. """ try: check_gh_installed() + if not skip_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" @@ -1337,7 +1687,10 @@ def release(version: str, dry_run: bool, no_edit: bool) -> None: if not dry_run: _create_tag_and_release(tag_name, release_notes, is_prerelease) - _trigger_pypi_publish(tag_name) + _trigger_pypi_publish(tag_name, wait=not skip_enterprise) + + if not skip_enterprise: + _release_enterprise(version, is_prerelease, dry_run) console.print(f"\n[green]✓[/green] Release [bold]{version}[/bold] complete!")