Skip to content

Commit b1b6f6f

Browse files
authored
feat(devtools): bump Flow canary on release (#6830)
1 parent 0c74f23 commit b1b6f6f

5 files changed

Lines changed: 381 additions & 22 deletions

File tree

lib/devtools/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,13 @@ devtools release 1.10.3 --skip-enterprise # skip enterprise release phase
3939
7. Opens a `[docs-freeze]` PR against main, polls until merged
4040
8. Tags main and creates GitHub release
4141
9. Triggers PyPI publish workflow
42-
10. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync`
43-
11. Creates enterprise bump PR, polls until merged
44-
12. Tags and creates GitHub release on enterprise repo
42+
10. Updates `crewAIInc/crew_deployment_test` to the exact CrewAI version,
43+
creates a bump PR, and waits for it to merge
44+
11. Updates `crewAIInc/flow_deployment_test` to the exact CrewAI version,
45+
creates a bump PR, and waits for it to merge
46+
12. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync`
47+
13. Creates enterprise bump PR, polls until merged
48+
14. Tags and creates GitHub release on enterprise repo
4549

4650
> The `docs-snapshots` CI guard rejects writes under `docs/v*/` and deletions/renames in `docs/images/` unless the PR title starts with `[docs-freeze]`. The release CLI sets that prefix automatically; manual edits to a frozen snapshot need the same prefix to land.
4751
>
@@ -66,4 +70,4 @@ Tag and release only (phase 2 of `release`). Run after the bump PR is merged.
6670
devtools tag
6771
devtools tag --no-edit
6872
devtools tag --dry-run
69-
```
73+
```

lib/devtools/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"openai>=1.83.0,<3",
1616
"python-dotenv>=1.2.2,<2",
1717
"pygithub~=1.59.1",
18+
"pyyaml~=6.0",
1819
"rich>=13.9.4",
1920
]
2021

lib/devtools/src/crewai_devtools/cli.py

Lines changed: 167 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
from pathlib import Path
66
import re
7+
import shlex
78
import subprocess
89
import sys
910
import tempfile
@@ -20,6 +21,7 @@
2021
from rich.panel import Panel
2122
from rich.prompt import Confirm
2223
import tomlkit
24+
import yaml
2325

2426
from crewai_devtools.docs_check import docs_check
2527
from crewai_devtools.docs_versioning import (
@@ -1421,7 +1423,10 @@ def _repin_crewai_install(run_value: str, version: str) -> str:
14211423
return "".join(result)
14221424

14231425

1424-
_DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test"
1426+
_DEPLOYMENT_TEST_REPOS: Final[tuple[str, ...]] = (
1427+
"crewAIInc/crew_deployment_test",
1428+
"crewAIInc/flow_deployment_test",
1429+
)
14251430

14261431
_PUBLISHED_WORKSPACE_PACKAGES: Final[tuple[str, ...]] = (
14271432
"crewai",
@@ -1435,45 +1440,183 @@ def _repin_crewai_install(run_value: str, version: str) -> str:
14351440
_PYPI_POLL_TIMEOUT: Final[int] = 600
14361441

14371442

1438-
def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
1439-
"""Update the deployment test repo to pin the new crewai version.
1443+
_CREWAI_REQUIREMENT_PATTERN: Final[re.Pattern[str]] = re.compile(
1444+
r"^crewai(?:\s*\[[^\]]+\])?(?![\w-])"
1445+
r"\s*(?:(?P<operator>===|==|~=|!=|>=|<=|>|<)\s*"
1446+
r"(?P<version>[^\s;]+))?",
1447+
re.IGNORECASE,
1448+
)
1449+
1450+
1451+
def _crewai_requirement_pin(requirement: str) -> str | None:
1452+
"""Return an exact CrewAI pin, or an empty string for a non-exact pin."""
1453+
match = _CREWAI_REQUIREMENT_PATTERN.match(requirement.strip())
1454+
if not match:
1455+
return None
1456+
if match.group("operator") != "==":
1457+
return ""
1458+
return match.group("version") or ""
1459+
1460+
1461+
def _pyproject_crewai_requirements(content: str) -> list[tuple[str, str]]:
1462+
"""Collect active CrewAI dependency requirements from pyproject content."""
1463+
requirements: list[tuple[str, str]] = []
1464+
doc = tomlkit.parse(content)
1465+
for key in ("dependencies", "optional-dependencies"):
1466+
deps = doc.get("project", {}).get(key)
1467+
if deps is None:
1468+
continue
1469+
dep_lists = deps.values() if isinstance(deps, Mapping) else [deps]
1470+
for dep_list in dep_lists:
1471+
for dep in dep_list:
1472+
spec = str(dep)
1473+
pin = _crewai_requirement_pin(spec)
1474+
if pin is not None:
1475+
requirements.append((spec, pin))
1476+
return requirements
1477+
1478+
1479+
def _workflow_run_commands(content: str) -> list[str]:
1480+
"""Extract shell commands from workflow ``run`` values."""
1481+
commands: list[str] = []
1482+
1483+
def collect_run_commands(node: object) -> None:
1484+
if isinstance(node, Mapping):
1485+
for key, value in node.items():
1486+
if key == "run" and isinstance(value, str):
1487+
commands.append(value)
1488+
collect_run_commands(value)
1489+
elif isinstance(node, list):
1490+
for value in node:
1491+
collect_run_commands(value)
1492+
1493+
collect_run_commands(yaml.safe_load(content))
1494+
return commands
1495+
1496+
1497+
def _workflow_crewai_requirements(content: str) -> list[tuple[str, str]]:
1498+
"""Collect CrewAI requirements from executable workflow install commands."""
1499+
requirements: list[tuple[str, str]] = []
1500+
for command in _workflow_run_commands(content):
1501+
normalized = command.replace("\\\n", " ")
1502+
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|\n")
1503+
lexer.whitespace = " \t\r"
1504+
lexer.whitespace_split = True
1505+
lexer.commenters = "#"
1506+
try:
1507+
tokens = list(lexer)
1508+
except ValueError:
1509+
continue
1510+
1511+
index = 0
1512+
while index < len(tokens):
1513+
command_lengths = (
1514+
(tokens[index : index + 3] == ["uv", "pip", "install"], 3),
1515+
(tokens[index : index + 2] == ["uv", "add"], 2),
1516+
(
1517+
tokens[index : index + 2]
1518+
in (["pip", "install"], ["pip3", "install"]),
1519+
2,
1520+
),
1521+
(
1522+
tokens[index : index + 4]
1523+
in (
1524+
["python", "-m", "pip", "install"],
1525+
["python3", "-m", "pip", "install"],
1526+
),
1527+
4,
1528+
),
1529+
)
1530+
install_length = next(
1531+
(length for matched, length in command_lengths if matched),
1532+
0,
1533+
)
1534+
if not install_length:
1535+
index += 1
1536+
continue
1537+
1538+
index += install_length
1539+
while index < len(tokens) and tokens[index] not in {
1540+
";",
1541+
"&&",
1542+
"||",
1543+
"|",
1544+
"\n",
1545+
}:
1546+
argument = tokens[index]
1547+
pin = _crewai_requirement_pin(argument)
1548+
if pin is not None:
1549+
requirements.append((argument, pin))
1550+
index += 1
1551+
return requirements
1552+
1553+
1554+
def _validate_deployment_repo_crewai_pin(
1555+
repo_dir: Path,
1556+
pyproject_content: str,
1557+
version: str,
1558+
) -> None:
1559+
"""Fail unless every effective canary CrewAI requirement has the exact pin."""
1560+
requirements = _pyproject_crewai_requirements(pyproject_content)
1561+
1562+
workflows_dir = repo_dir / ".github" / "workflows"
1563+
if workflows_dir.exists():
1564+
for workflow in workflows_dir.iterdir():
1565+
if workflow.is_file() and workflow.suffix in (".yml", ".yaml"):
1566+
requirements.extend(
1567+
_workflow_crewai_requirements(workflow.read_text(encoding="utf-8"))
1568+
)
1569+
1570+
if not requirements:
1571+
raise RuntimeError(f"No effective CrewAI dependency found in {repo_dir.name}")
1572+
1573+
mismatches = [spec for spec, pin in requirements if pin != version]
1574+
if mismatches:
1575+
found = ", ".join(repr(spec) for spec in mismatches)
1576+
raise RuntimeError(
1577+
f"CrewAI dependencies in {repo_dir.name} must all pin {version}; "
1578+
f"found {found}"
1579+
)
14401580

1441-
Clones the repo, updates the crewai[tools] pin in pyproject.toml
1581+
1582+
def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) -> None:
1583+
"""Update a deployment test repo to pin the new crewai version.
1584+
1585+
Clones the repo, updates the CrewAI pin in pyproject.toml
14421586
and any crewai[extras] pins in .github/workflows, regenerates the
14431587
lockfile, commits to a branch, pushes, opens a PR against main,
14441588
then polls until the PR is merged (or closed).
14451589
14461590
Args:
1591+
repo: GitHub repository containing the deployment canary.
14471592
version: New crewai version string.
14481593
is_prerelease: Whether this is a pre-release version.
14491594
"""
1450-
console.print(
1451-
f"\n[bold cyan]Updating {_DEPLOYMENT_TEST_REPO} to {version}[/bold cyan]"
1452-
)
1595+
console.print(f"\n[bold cyan]Updating {repo} to {version}[/bold cyan]")
14531596

14541597
with tempfile.TemporaryDirectory() as tmp:
1455-
repo_dir = Path(tmp) / "crew_deployment_test"
1456-
run_command(["gh", "repo", "clone", _DEPLOYMENT_TEST_REPO, str(repo_dir)])
1457-
console.print(f"[green]✓[/green] Cloned {_DEPLOYMENT_TEST_REPO}")
1598+
repo_dir = Path(tmp) / repo.rsplit("/", 1)[-1]
1599+
run_command(["gh", "repo", "clone", repo, str(repo_dir)])
1600+
console.print(f"[green]✓[/green] Cloned {repo}")
14581601

14591602
pyproject = repo_dir / "pyproject.toml"
14601603
content = pyproject.read_text()
14611604
new_content = _pin_crewai_deps(content, version)
14621605
pyproject_changed = new_content != content
14631606
if pyproject_changed:
14641607
pyproject.write_text(new_content)
1465-
console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}")
1608+
console.print(f"[green]✓[/green] Updated crewai pin to {version}")
14661609
else:
1467-
console.print(
1468-
"[yellow]Warning:[/yellow] No crewai[tools] pin found to update"
1469-
)
1610+
console.print("[yellow]Warning:[/yellow] No crewai pin found to update")
14701611

14711612
updated_workflows = _update_repo_workflows_crewai_pins(repo_dir, version)
14721613
for wf in updated_workflows:
14731614
console.print(
14741615
f"[green]✓[/green] Updated crewai pin in {wf.relative_to(repo_dir)}"
14751616
)
14761617

1618+
_validate_deployment_repo_crewai_pin(repo_dir, new_content, version)
1619+
14771620
if not pyproject_changed and not updated_workflows:
14781621
console.print("[yellow]Nothing to update; skipping commit and PR.[/yellow]")
14791622
return
@@ -1535,12 +1678,18 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
15351678
],
15361679
cwd=repo_dir,
15371680
)
1538-
console.print(f"[green]✓[/green] Opened PR on {_DEPLOYMENT_TEST_REPO}")
1681+
console.print(f"[green]✓[/green] Opened PR on {repo}")
15391682
console.print(f"[cyan]PR URL:[/cyan] {pr_url.strip()}")
15401683

15411684
_wait_for_pr_merged(branch, repo_dir)
15421685

15431686

1687+
def _update_deployment_test_repos(version: str, is_prerelease: bool) -> None:
1688+
"""Pin and merge the release version in every deployment canary repo."""
1689+
for repo in _DEPLOYMENT_TEST_REPOS:
1690+
_update_deployment_test_repo(repo, version, is_prerelease)
1691+
1692+
15441693
def _wait_for_pypi(package: str, version: str) -> None:
15451694
"""Poll PyPI until a specific package version is available.
15461695
@@ -2352,13 +2501,13 @@ def release(
23522501

23532502
try:
23542503
if not dry_run:
2355-
_update_deployment_test_repo(version, is_prerelease)
2504+
_update_deployment_test_repos(version, is_prerelease)
23562505
except BaseException as e:
23572506
_print_release_error(e)
23582507
_resume_hint(
2359-
f"Phase 2 failed updating deployment test repo. "
2508+
f"Phase 2 failed updating deployment test repos. "
23602509
f"Tag, release, and PyPI are done.\n"
2361-
f"Fix the issue and update {_DEPLOYMENT_TEST_REPO} manually."
2510+
"Fix the issue and update the Crew and Flow canary repos manually."
23622511
f"{enterprise_hint}"
23632512
)
23642513
sys.exit(1)

0 commit comments

Comments
 (0)