44import os
55from pathlib import Path
66import re
7+ import shlex
78import subprocess
89import sys
910import tempfile
2021from rich .panel import Panel
2122from rich .prompt import Confirm
2223import tomlkit
24+ import yaml
2325
2426from crewai_devtools .docs_check import docs_check
2527from 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+
15441693def _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