From 99d675def19543f06c5642e720d40b126ea8f0e1 Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 5 Aug 2026 11:19:30 -0700 Subject: [PATCH 1/5] feat(devtools): bump Flow canary on release --- lib/devtools/README.md | 12 ++++--- lib/devtools/src/crewai_devtools/cli.py | 42 ++++++++++++++----------- lib/devtools/tests/test_toml_updates.py | 19 +++++++++++ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/lib/devtools/README.md b/lib/devtools/README.md index ae3c5a5085..2faac201f3 100644 --- a/lib/devtools/README.md +++ b/lib/devtools/README.md @@ -39,9 +39,13 @@ devtools release 1.10.3 --skip-enterprise # skip enterprise release phase 7. Opens a `[docs-freeze]` PR against main, polls until merged 8. Tags main and creates GitHub release 9. Triggers PyPI publish workflow -10. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync` -11. Creates enterprise bump PR, polls until merged -12. Tags and creates GitHub release on enterprise repo +10. Updates `crewAIInc/crew_deployment_test` to the exact CrewAI version, + creates a bump PR, and waits for it to merge +11. Updates `crewAIInc/flow_deployment_test` to the exact CrewAI version, + creates a bump PR, and waits for it to merge +12. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync` +13. Creates enterprise bump PR, polls until merged +14. Tags and creates GitHub release on enterprise repo > 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. > @@ -66,4 +70,4 @@ Tag and release only (phase 2 of `release`). Run after the bump PR is merged. devtools tag devtools tag --no-edit devtools tag --dry-run -``` \ No newline at end of file +``` diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index c749bc6686..418f59a3b6 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -1421,7 +1421,10 @@ def _repin_crewai_install(run_value: str, version: str) -> str: return "".join(result) -_DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test" +_DEPLOYMENT_TEST_REPOS: Final[tuple[str, ...]] = ( + "crewAIInc/crew_deployment_test", + "crewAIInc/flow_deployment_test", +) _PUBLISHED_WORKSPACE_PACKAGES: Final[tuple[str, ...]] = ( "crewai", @@ -1435,26 +1438,25 @@ def _repin_crewai_install(run_value: str, version: str) -> str: _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. +def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) -> None: + """Update a deployment test repo to pin the new crewai version. - Clones the repo, updates the crewai[tools] pin in pyproject.toml + Clones the repo, updates the CrewAI pin in pyproject.toml and any crewai[extras] pins in .github/workflows, regenerates the lockfile, commits to a branch, pushes, opens a PR against main, then polls until the PR is merged (or closed). Args: + repo: GitHub repository containing the deployment canary. 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]" - ) + console.print(f"\n[bold cyan]Updating {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}") + repo_dir = Path(tmp) / repo.rsplit("/", 1)[-1] + run_command(["gh", "repo", "clone", repo, str(repo_dir)]) + console.print(f"[green]✓[/green] Cloned {repo}") pyproject = repo_dir / "pyproject.toml" content = pyproject.read_text() @@ -1462,11 +1464,9 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None: pyproject_changed = new_content != content if pyproject_changed: pyproject.write_text(new_content) - console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}") + console.print(f"[green]✓[/green] Updated crewai pin to {version}") else: - console.print( - "[yellow]Warning:[/yellow] No crewai[tools] pin found to update" - ) + console.print("[yellow]Warning:[/yellow] No crewai pin found to update") updated_workflows = _update_repo_workflows_crewai_pins(repo_dir, version) for wf in updated_workflows: @@ -1535,12 +1535,18 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None: ], cwd=repo_dir, ) - console.print(f"[green]✓[/green] Opened PR on {_DEPLOYMENT_TEST_REPO}") + console.print(f"[green]✓[/green] Opened PR on {repo}") console.print(f"[cyan]PR URL:[/cyan] {pr_url.strip()}") _wait_for_pr_merged(branch, repo_dir) +def _update_deployment_test_repos(version: str, is_prerelease: bool) -> None: + """Pin and merge the release version in every deployment canary repo.""" + for repo in _DEPLOYMENT_TEST_REPOS: + _update_deployment_test_repo(repo, version, is_prerelease) + + def _wait_for_pypi(package: str, version: str) -> None: """Poll PyPI until a specific package version is available. @@ -2352,13 +2358,13 @@ def release( try: if not dry_run: - _update_deployment_test_repo(version, is_prerelease) + _update_deployment_test_repos(version, is_prerelease) except BaseException as e: _print_release_error(e) _resume_hint( - f"Phase 2 failed updating deployment test repo. " + f"Phase 2 failed updating deployment test repos. " f"Tag, release, and PyPI are done.\n" - f"Fix the issue and update {_DEPLOYMENT_TEST_REPO} manually." + "Fix the issue and update the Crew and Flow canary repos manually." f"{enterprise_hint}" ) sys.exit(1) diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py index 80b18648d1..aec1be12a7 100644 --- a/lib/devtools/tests/test_toml_updates.py +++ b/lib/devtools/tests/test_toml_updates.py @@ -3,6 +3,7 @@ from pathlib import Path from textwrap import dedent +from crewai_devtools import cli as devtools_cli from crewai_devtools.cli import ( _DEFAULT_WORKSPACE_PACKAGES, _pin_crewai_deps, @@ -13,6 +14,24 @@ ) +def test_release_updates_crew_and_flow_canary_repositories(monkeypatch) -> None: + updates = [] + monkeypatch.setattr( + devtools_cli, + "_update_deployment_test_repo", + lambda repo, version, is_prerelease: updates.append( + (repo, version, is_prerelease) + ), + ) + + devtools_cli._update_deployment_test_repos("2.0.0a1", True) + + assert updates == [ + ("crewAIInc/crew_deployment_test", "2.0.0a1", True), + ("crewAIInc/flow_deployment_test", "2.0.0a1", True), + ] + + class TestUpdatePyprojectVersion: def test_updates_version(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" From 6e81e6420c27b2663ad8910e60693236b203383d Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 5 Aug 2026 14:21:17 -0700 Subject: [PATCH 2/5] fix(devtools): validate canary CrewAI pins --- lib/devtools/src/crewai_devtools/cli.py | 32 +++++++++++++++++++++++++ lib/devtools/tests/test_toml_updates.py | 31 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index 418f59a3b6..f3e8a3cd19 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -1438,6 +1438,36 @@ def _repin_crewai_install(run_value: str, version: str) -> str: _PYPI_POLL_TIMEOUT: Final[int] = 600 +def _has_exact_crewai_pin(content: str, version: str) -> bool: + """Return whether text contains an exact CrewAI dependency pin.""" + pattern = re.compile( + rf"\bcrewai(?:\[[^\]\s\"']+\])?=={re.escape(version)}(?=$|[\s\"'])" + ) + return pattern.search(content) is not None + + +def _validate_deployment_repo_crewai_pin( + repo_dir: Path, + pyproject_content: str, + version: str, +) -> None: + """Fail unless a deployment canary contains the requested CrewAI pin.""" + if _has_exact_crewai_pin(pyproject_content, version): + return + + workflows_dir = repo_dir / ".github" / "workflows" + if workflows_dir.exists(): + for workflow in workflows_dir.iterdir(): + if workflow.suffix in (".yml", ".yaml") and _has_exact_crewai_pin( + workflow.read_text(), version + ): + return + + raise RuntimeError( + f"No exact CrewAI {version} dependency pin found in {repo_dir.name}" + ) + + def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) -> None: """Update a deployment test repo to pin the new crewai version. @@ -1474,6 +1504,8 @@ def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) - f"[green]✓[/green] Updated crewai pin in {wf.relative_to(repo_dir)}" ) + _validate_deployment_repo_crewai_pin(repo_dir, new_content, version) + if not pyproject_changed and not updated_workflows: console.print("[yellow]Nothing to update; skipping commit and PR.[/yellow]") return diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py index aec1be12a7..14853c8ae9 100644 --- a/lib/devtools/tests/test_toml_updates.py +++ b/lib/devtools/tests/test_toml_updates.py @@ -6,12 +6,15 @@ from crewai_devtools import cli as devtools_cli from crewai_devtools.cli import ( _DEFAULT_WORKSPACE_PACKAGES, + _has_exact_crewai_pin, _pin_crewai_deps, _repin_crewai_install, + _validate_deployment_repo_crewai_pin, update_pyproject_dependencies, update_pyproject_version, update_template_dependencies, ) +import pytest def test_release_updates_crew_and_flow_canary_repositories(monkeypatch) -> None: @@ -32,6 +35,34 @@ def test_release_updates_crew_and_flow_canary_repositories(monkeypatch) -> None: ] +def test_exact_crewai_pin_accepts_plain_and_extra_dependencies() -> None: + assert _has_exact_crewai_pin('"crewai==2.0.0"', "2.0.0") + assert _has_exact_crewai_pin('"crewai[tools]==2.0.0"', "2.0.0") + assert not _has_exact_crewai_pin('"crewai>=2.0.0"', "2.0.0") + assert not _has_exact_crewai_pin('"crewai==2.0.0a1"', "2.0.0") + + +def test_deployment_repo_validation_rejects_missing_crewai_pin(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match=r"No exact CrewAI 2\.0\.0 dependency pin"): + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + +def test_deployment_repo_validation_accepts_workflow_pin(tmp_path: Path) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text('run: uv pip install "crewai[a2a]==2.0.0"\n') + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + class TestUpdatePyprojectVersion: def test_updates_version(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" From 5e7a18e02afc01878de7ea361d46f18c4b0591ae Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 5 Aug 2026 14:41:52 -0700 Subject: [PATCH 3/5] fix(devtools): validate effective canary pins --- lib/devtools/src/crewai_devtools/cli.py | 153 +++++++++++++++++++++--- lib/devtools/tests/test_toml_updates.py | 75 ++++++++++-- 2 files changed, 202 insertions(+), 26 deletions(-) diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index f3e8a3cd19..2decccc7b7 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -4,6 +4,7 @@ import os from pathlib import Path import re +import shlex import subprocess import sys import tempfile @@ -1438,12 +1439,126 @@ def _repin_crewai_install(run_value: str, version: str) -> str: _PYPI_POLL_TIMEOUT: Final[int] = 600 -def _has_exact_crewai_pin(content: str, version: str) -> bool: - """Return whether text contains an exact CrewAI dependency pin.""" - pattern = re.compile( - rf"\bcrewai(?:\[[^\]\s\"']+\])?=={re.escape(version)}(?=$|[\s\"'])" - ) - return pattern.search(content) is not None +_CREWAI_REQUIREMENT_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^crewai(?:\s*\[[^\]]+\])?(?![\w-])" + r"\s*(?:(?P===|==|~=|!=|>=|<=|>|<)\s*" + r"(?P[^\s;]+))?", + re.IGNORECASE, +) + + +def _crewai_requirement_pin(requirement: str) -> str | None: + """Return an exact CrewAI pin, or an empty string for a non-exact pin.""" + match = _CREWAI_REQUIREMENT_PATTERN.match(requirement.strip()) + if not match: + return None + if match.group("operator") != "==": + return "" + return match.group("version") or "" + + +def _pyproject_crewai_requirements(content: str) -> list[tuple[str, str]]: + """Collect active CrewAI dependency requirements from pyproject content.""" + requirements: list[tuple[str, str]] = [] + doc = tomlkit.parse(content) + for key in ("dependencies", "optional-dependencies"): + deps = doc.get("project", {}).get(key) + if deps is None: + continue + dep_lists = deps.values() if isinstance(deps, Mapping) else [deps] + for dep_list in dep_lists: + for dep in dep_list: + spec = str(dep) + pin = _crewai_requirement_pin(spec) + if pin is not None: + requirements.append((spec, pin)) + return requirements + + +def _workflow_run_commands(content: str) -> list[str]: + """Extract scalar and block ``run`` command values from workflow YAML text.""" + lines = content.splitlines() + commands: list[str] = [] + index = 0 + while index < len(lines): + line = lines[index] + stripped = line.lstrip() + if stripped.startswith("- run:"): + stripped = stripped[2:].lstrip() + if not stripped.startswith("run:"): + index += 1 + continue + + indent = len(line) - len(line.lstrip()) + value = stripped.removeprefix("run:").strip() + if value not in {"|", "|-", "|+", ">", ">-", ">+"}: + commands.append(value) + index += 1 + continue + + block: list[str] = [] + index += 1 + while index < len(lines): + block_line = lines[index] + if ( + block_line.strip() + and len(block_line) - len(block_line.lstrip()) <= indent + ): + break + block.append(block_line.strip()) + index += 1 + commands.append("\n".join(block)) + return commands + + +def _workflow_crewai_requirements(content: str) -> list[tuple[str, str]]: + """Collect CrewAI requirements from executable workflow install commands.""" + requirements: list[tuple[str, str]] = [] + for command in _workflow_run_commands(content): + normalized = command.replace("\\\n", " ").replace("\n", " ; ") + lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|") + lexer.whitespace_split = True + lexer.commenters = "#" + try: + tokens = list(lexer) + except ValueError: + continue + + index = 0 + while index < len(tokens): + command_lengths = ( + (tokens[index : index + 3] == ["uv", "pip", "install"], 3), + (tokens[index : index + 2] == ["uv", "add"], 2), + ( + tokens[index : index + 2] + in (["pip", "install"], ["pip3", "install"]), + 2, + ), + ( + tokens[index : index + 4] + in ( + ["python", "-m", "pip", "install"], + ["python3", "-m", "pip", "install"], + ), + 4, + ), + ) + install_length = next( + (length for matched, length in command_lengths if matched), + 0, + ) + if not install_length: + index += 1 + continue + + index += install_length + while index < len(tokens) and tokens[index] not in {";", "&&", "||", "|"}: + argument = tokens[index] + pin = _crewai_requirement_pin(argument) + if pin is not None: + requirements.append((argument, pin)) + index += 1 + return requirements def _validate_deployment_repo_crewai_pin( @@ -1451,21 +1566,25 @@ def _validate_deployment_repo_crewai_pin( pyproject_content: str, version: str, ) -> None: - """Fail unless a deployment canary contains the requested CrewAI pin.""" - if _has_exact_crewai_pin(pyproject_content, version): - return + """Fail unless every effective canary CrewAI requirement has the exact pin.""" + requirements = _pyproject_crewai_requirements(pyproject_content) workflows_dir = repo_dir / ".github" / "workflows" if workflows_dir.exists(): for workflow in workflows_dir.iterdir(): - if workflow.suffix in (".yml", ".yaml") and _has_exact_crewai_pin( - workflow.read_text(), version - ): - return - - raise RuntimeError( - f"No exact CrewAI {version} dependency pin found in {repo_dir.name}" - ) + if workflow.suffix in (".yml", ".yaml"): + requirements.extend(_workflow_crewai_requirements(workflow.read_text())) + + if not requirements: + raise RuntimeError(f"No effective CrewAI dependency found in {repo_dir.name}") + + mismatches = [spec for spec, pin in requirements if pin != version] + if mismatches: + found = ", ".join(repr(spec) for spec in mismatches) + raise RuntimeError( + f"CrewAI dependencies in {repo_dir.name} must all pin {version}; " + f"found {found}" + ) def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) -> None: diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py index 14853c8ae9..6f61a6af2b 100644 --- a/lib/devtools/tests/test_toml_updates.py +++ b/lib/devtools/tests/test_toml_updates.py @@ -6,7 +6,6 @@ from crewai_devtools import cli as devtools_cli from crewai_devtools.cli import ( _DEFAULT_WORKSPACE_PACKAGES, - _has_exact_crewai_pin, _pin_crewai_deps, _repin_crewai_install, _validate_deployment_repo_crewai_pin, @@ -35,15 +34,8 @@ def test_release_updates_crew_and_flow_canary_repositories(monkeypatch) -> None: ] -def test_exact_crewai_pin_accepts_plain_and_extra_dependencies() -> None: - assert _has_exact_crewai_pin('"crewai==2.0.0"', "2.0.0") - assert _has_exact_crewai_pin('"crewai[tools]==2.0.0"', "2.0.0") - assert not _has_exact_crewai_pin('"crewai>=2.0.0"', "2.0.0") - assert not _has_exact_crewai_pin('"crewai==2.0.0a1"', "2.0.0") - - def test_deployment_repo_validation_rejects_missing_crewai_pin(tmp_path: Path) -> None: - with pytest.raises(RuntimeError, match=r"No exact CrewAI 2\.0\.0 dependency pin"): + with pytest.raises(RuntimeError, match="No effective CrewAI dependency"): _validate_deployment_repo_crewai_pin( tmp_path, '[project]\ndependencies = ["requests>=2"]\n', @@ -63,6 +55,71 @@ def test_deployment_repo_validation_accepts_workflow_pin(tmp_path: Path) -> None ) +def test_deployment_repo_validation_rejects_mixed_versions(tmp_path: Path) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text('run: uv pip install "crewai[a2a]==2.0.0"\n') + + with pytest.raises(RuntimeError, match=r"must all pin 2\.0\.0"): + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["crewai==1.0.0"]\n', + "2.0.0", + ) + + +def test_deployment_repo_validation_ignores_comments_and_echo(tmp_path: Path) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text( + 'run: echo "crewai==2.0.0"\n# run: pip install crewai==2.0.0\n' + ) + + with pytest.raises(RuntimeError, match=r"must all pin 2\.0\.0"): + _validate_deployment_repo_crewai_pin( + tmp_path, + ( + "# documented pin: crewai==2.0.0\n" + '[project]\ndependencies = ["crewai>=1.0"]\n' + ), + "2.0.0", + ) + + +def test_deployment_repo_validation_accepts_spaced_extras_and_marker( + tmp_path: Path, +) -> None: + _validate_deployment_repo_crewai_pin( + tmp_path, + ( + "[project]\ndependencies = [\n" + " \"crewai[tools, embeddings]==2.0.0; python_version >= '3.10'\",\n" + "]\n" + ), + "2.0.0", + ) + + +def test_deployment_repo_validation_reads_multiline_workflow_install( + tmp_path: Path, +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text( + "steps:\n" + " - name: Install\n" + " run: |\n" + " uv pip install \\\n" + " \"crewai[tools, embeddings]==2.0.0; python_version >= '3.10'\"\n" + ) + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + class TestUpdatePyprojectVersion: def test_updates_version(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" From e054285e55918592c5b00c414f485a5eaec5f5ea Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 5 Aug 2026 15:02:54 -0700 Subject: [PATCH 4/5] fix(devtools): handle workflow command boundaries --- lib/devtools/src/crewai_devtools/cli.py | 22 +++++--- lib/devtools/tests/test_toml_updates.py | 71 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index 2decccc7b7..538c69503b 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -1507,7 +1507,8 @@ def _workflow_run_commands(content: str) -> list[str]: break block.append(block_line.strip()) index += 1 - commands.append("\n".join(block)) + separator = " " if value.startswith(">") else "\n" + commands.append(separator.join(block)) return commands @@ -1515,8 +1516,9 @@ def _workflow_crewai_requirements(content: str) -> list[tuple[str, str]]: """Collect CrewAI requirements from executable workflow install commands.""" requirements: list[tuple[str, str]] = [] for command in _workflow_run_commands(content): - normalized = command.replace("\\\n", " ").replace("\n", " ; ") - lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|") + normalized = command.replace("\\\n", " ") + lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|\n") + lexer.whitespace = " \t\r" lexer.whitespace_split = True lexer.commenters = "#" try: @@ -1552,7 +1554,13 @@ def _workflow_crewai_requirements(content: str) -> list[tuple[str, str]]: continue index += install_length - while index < len(tokens) and tokens[index] not in {";", "&&", "||", "|"}: + while index < len(tokens) and tokens[index] not in { + ";", + "&&", + "||", + "|", + "\n", + }: argument = tokens[index] pin = _crewai_requirement_pin(argument) if pin is not None: @@ -1572,8 +1580,10 @@ def _validate_deployment_repo_crewai_pin( workflows_dir = repo_dir / ".github" / "workflows" if workflows_dir.exists(): for workflow in workflows_dir.iterdir(): - if workflow.suffix in (".yml", ".yaml"): - requirements.extend(_workflow_crewai_requirements(workflow.read_text())) + if workflow.is_file() and workflow.suffix in (".yml", ".yaml"): + requirements.extend( + _workflow_crewai_requirements(workflow.read_text(encoding="utf-8")) + ) if not requirements: raise RuntimeError(f"No effective CrewAI dependency found in {repo_dir.name}") diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py index 6f61a6af2b..6bf05a590f 100644 --- a/lib/devtools/tests/test_toml_updates.py +++ b/lib/devtools/tests/test_toml_updates.py @@ -75,6 +75,17 @@ def test_deployment_repo_validation_ignores_comments_and_echo(tmp_path: Path) -> 'run: echo "crewai==2.0.0"\n# run: pip install crewai==2.0.0\n' ) + with pytest.raises(RuntimeError, match="No effective CrewAI dependency"): + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + +def test_deployment_repo_validation_ignores_pyproject_comment_pin( + tmp_path: Path, +) -> None: with pytest.raises(RuntimeError, match=r"must all pin 2\.0\.0"): _validate_deployment_repo_crewai_pin( tmp_path, @@ -120,6 +131,66 @@ def test_deployment_repo_validation_reads_multiline_workflow_install( ) +def test_deployment_repo_validation_reads_install_after_comment( + tmp_path: Path, +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text( + "steps:\n" + " - name: Install\n" + " run: |\n" + " # Install the canary dependency\n" + ' uv pip install "crewai==2.0.0"\n', + encoding="utf-8", + ) + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + +def test_deployment_repo_validation_reads_folded_workflow_install( + tmp_path: Path, +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text( + "steps:\n" + " - name: Install\n" + " run: >\n" + " uv pip install\n" + ' "crewai==2.0.0"\n', + encoding="utf-8", + ) + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + +def test_deployment_repo_validation_skips_non_file_workflow_entries( + tmp_path: Path, +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "ignored.yml").mkdir() + (workflows / "test.yaml").write_text( + '# UTF-8 workflow: déploiement\nrun: uv pip install "crewai==2.0.0"\n', + encoding="utf-8", + ) + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + class TestUpdatePyprojectVersion: def test_updates_version(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" From 1fc98a8840240702f1dff89ef1d4eca2262f00c0 Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 5 Aug 2026 15:25:39 -0700 Subject: [PATCH 5/5] fix(devtools): parse workflow YAML scalars --- lib/devtools/pyproject.toml | 1 + lib/devtools/src/crewai_devtools/cli.py | 44 ++++++++----------------- lib/devtools/tests/test_toml_updates.py | 25 ++++++++++++++ uv.lock | 2 ++ 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/lib/devtools/pyproject.toml b/lib/devtools/pyproject.toml index 98ba51f595..2c54ab650a 100644 --- a/lib/devtools/pyproject.toml +++ b/lib/devtools/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "openai>=1.83.0,<3", "python-dotenv>=1.2.2,<2", "pygithub~=1.59.1", + "pyyaml~=6.0", "rich>=13.9.4", ] diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index 538c69503b..a3c003580f 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -21,6 +21,7 @@ from rich.panel import Panel from rich.prompt import Confirm import tomlkit +import yaml from crewai_devtools.docs_check import docs_check from crewai_devtools.docs_versioning import ( @@ -1476,39 +1477,20 @@ def _pyproject_crewai_requirements(content: str) -> list[tuple[str, str]]: def _workflow_run_commands(content: str) -> list[str]: - """Extract scalar and block ``run`` command values from workflow YAML text.""" - lines = content.splitlines() + """Extract shell commands from workflow ``run`` values.""" commands: list[str] = [] - index = 0 - while index < len(lines): - line = lines[index] - stripped = line.lstrip() - if stripped.startswith("- run:"): - stripped = stripped[2:].lstrip() - if not stripped.startswith("run:"): - index += 1 - continue - indent = len(line) - len(line.lstrip()) - value = stripped.removeprefix("run:").strip() - if value not in {"|", "|-", "|+", ">", ">-", ">+"}: - commands.append(value) - index += 1 - continue - - block: list[str] = [] - index += 1 - while index < len(lines): - block_line = lines[index] - if ( - block_line.strip() - and len(block_line) - len(block_line.lstrip()) <= indent - ): - break - block.append(block_line.strip()) - index += 1 - separator = " " if value.startswith(">") else "\n" - commands.append(separator.join(block)) + def collect_run_commands(node: object) -> None: + if isinstance(node, Mapping): + for key, value in node.items(): + if key == "run" and isinstance(value, str): + commands.append(value) + collect_run_commands(value) + elif isinstance(node, list): + for value in node: + collect_run_commands(value) + + collect_run_commands(yaml.safe_load(content)) return commands diff --git a/lib/devtools/tests/test_toml_updates.py b/lib/devtools/tests/test_toml_updates.py index 6bf05a590f..6fd9fa1fd7 100644 --- a/lib/devtools/tests/test_toml_updates.py +++ b/lib/devtools/tests/test_toml_updates.py @@ -55,6 +55,31 @@ def test_deployment_repo_validation_accepts_workflow_pin(tmp_path: Path) -> None ) +@pytest.mark.parametrize( + "run_value", + [ + "'uv pip install \"crewai==2.0.0\"'", + '"uv pip install \\"crewai==2.0.0\\""', + ], +) +def test_deployment_repo_validation_accepts_quoted_workflow_pin( + tmp_path: Path, + run_value: str, +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "test.yml").write_text( + f"run: {run_value}\n", + encoding="utf-8", + ) + + _validate_deployment_repo_crewai_pin( + tmp_path, + '[project]\ndependencies = ["requests>=2"]\n', + "2.0.0", + ) + + def test_deployment_repo_validation_rejects_mixed_versions(tmp_path: Path) -> None: workflows = tmp_path / ".github" / "workflows" workflows.mkdir(parents=True) diff --git a/uv.lock b/uv.lock index 0498c5df92..dc36588e02 100644 --- a/uv.lock +++ b/uv.lock @@ -1564,6 +1564,7 @@ dependencies = [ { name = "openai" }, { name = "pygithub" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "tomlkit" }, ] @@ -1574,6 +1575,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.83.0,<3" }, { name = "pygithub", specifier = "~=1.59.1" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, + { name = "pyyaml", specifier = "~=6.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "tomlkit", specifier = "~=0.13.2" }, ]