Compare commits

...

5 Commits
1.12.1 ... main

Author SHA1 Message Date
João Moura
6193e082e1 docs: update changelog and version for v1.12.2 (#5103)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
2026-03-26 03:54:26 -03:00
João Moura
33f33c6fcc feat: bump versions to 1.12.2 (#5101) 2026-03-26 03:33:10 -03:00
alex-clawd
74976b157d fix: preserve method return value as flow output for @human_feedback with emit (#5099)
* fix: preserve method return value as flow output for @human_feedback with emit

When a @human_feedback decorated method with emit= is the final method in a
flow (no downstream listeners triggered), the flow's final output was
incorrectly set to the collapsed outcome string (e.g., 'approved') instead
of the method's actual return value (e.g., a state dict).

Root cause: _process_feedback() returns the collapsed_outcome string when
emit is set, and this string was being stored as the method's result in
_method_outputs.

The fix:
1. In human_feedback.py: After _process_feedback, stash the real method_output
   on the flow instance as _human_feedback_method_output when emit is set.

2. In flow.py: After appending a method result to _method_outputs, check if
   _human_feedback_method_output is set. If so, replace the last entry with
   the stashed real output and clear the stash.

This ensures:
- Routing still works correctly (collapsed outcome used for @listen matching)
- The flow's final result is the actual method return value
- If downstream listeners execute, their results become the final output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: ruff format flow.py

* fix: use per-method dict stash for concurrency safety and None returns

Addresses review comments:
- Replace single flow-level slot with dict keyed by method name,
  safe under concurrent @human_feedback+emit execution
- Dict key presence (not value) indicates stashed output,
  correctly preserving None return values
- Added test for None return value preservation

---------

Co-authored-by: Joao Moura <joao@crewai.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 03:28:17 -03:00
Greyson LaLonde
bd03f6cf64 feat: add enterprise release phase to devtools release
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
2026-03-26 12:22:37 +08:00
Rip&Tear
a91cd1a7d7 Revise security policy and reporting instructions (#5096)
* Revise security policy and reporting instructions

Updated the security reporting process and contact details.

* Update .github/security.md
---------
2026-03-26 10:50:21 +08:00
21 changed files with 2566 additions and 99 deletions

50
.github/security.md vendored
View File

@@ -1,50 +1,12 @@
## CrewAI Security Policy
We are committed to protecting the confidentiality, integrity, and availability of the CrewAI ecosystem. This policy explains how to report potential vulnerabilities and what you can expect from us when you do.
### Scope
We welcome reports for vulnerabilities that could impact:
- CrewAI-maintained source code and repositories
- CrewAI-operated infrastructure and services
- Official CrewAI releases, packages, and distributions
Issues affecting clearly unaffiliated third-party services or user-generated content are out of scope, unless you can demonstrate a direct impact on CrewAI systems or customers.
We are committed to protecting the confidentiality, integrity, and availability of the
CrewAI ecosystem.
### How to Report
- **Please do not** disclose vulnerabilities via public GitHub issues, pull requests, or social media.
- Email detailed reports to **security@crewai.com** with the subject line `Security Report`.
- If you need to share large files or sensitive artifacts, mention it in your email and we will coordinate a secure transfer method.
Please submit reports to **crewai-vdp-ess@submit.bugcrowd.com**
### What to Include
Providing comprehensive information enables us to validate the issue quickly:
- **Vulnerability overview** — a concise description and classification (e.g., RCE, privilege escalation)
- **Affected components** — repository, branch, tag, or deployed service along with relevant file paths or endpoints
- **Reproduction steps** — detailed, step-by-step instructions; include logs, screenshots, or screen recordings when helpful
- **Proof-of-concept** — exploit details or code that demonstrates the impact (if available)
- **Impact analysis** — severity assessment, potential exploitation scenarios, and any prerequisites or special configurations
### Our Commitment
- **Acknowledgement:** We aim to acknowledge your report within two business days.
- **Communication:** We will keep you informed about triage results, remediation progress, and planned release timelines.
- **Resolution:** Confirmed vulnerabilities will be prioritized based on severity and fixed as quickly as possible.
- **Recognition:** We currently do not run a bug bounty program; any rewards or recognition are issued at CrewAI's discretion.
### Coordinated Disclosure
We ask that you allow us a reasonable window to investigate and remediate confirmed issues before any public disclosure. We will coordinate publication timelines with you whenever possible.
### Safe Harbor
We will not pursue or support legal action against individuals who, in good faith:
- Follow this policy and refrain from violating any applicable laws
- Avoid privacy violations, data destruction, or service disruption
- Limit testing to systems in scope and respect rate limits and terms of service
If you are unsure whether your testing is covered, please contact us at **security@crewai.com** before proceeding.
- **Please do not** disclose vulnerabilities via public GitHub issues, pull requests,
or social media
- Reports submitted via channels other than this Bugcrowd submission email will not be reviewed and will be dismissed

View File

@@ -4,6 +4,29 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="25 مارس 2026">
## v1.12.2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## ما الذي تغير
### الميزات
- إضافة مرحلة إصدار المؤسسات إلى إصدار أدوات المطورين
### إصلاحات الأخطاء
- الحفاظ على قيمة إرجاع الطريقة كإخراج تدفق لـ @human_feedback مع emit
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.1
- مراجعة سياسة الأمان وتعليمات الإبلاغ
## المساهمون
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 مارس 2026">
## v1.12.1

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,29 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Mar 25, 2026">
## v1.12.2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## What's Changed
### Features
- Add enterprise release phase to devtools release
### Bug Fixes
- Preserve method return value as flow output for @human_feedback with emit
### Documentation
- Update changelog and version for v1.12.1
- Revise security policy and reporting instructions
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="Mar 25, 2026">
## v1.12.1

View File

@@ -4,6 +4,29 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 3월 25일">
## v1.12.2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## 변경 사항
### 기능
- devtools 릴리스에 기업 릴리스 단계 추가
### 버그 수정
- @human_feedback과 함께 emit을 사용할 때 메서드 반환 값을 흐름 출력으로 유지
### 문서
- v1.12.1에 대한 변경 로그 및 버전 업데이트
- 보안 정책 및 보고 지침 수정
## 기여자
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="2026년 3월 25일">
## v1.12.1

View File

@@ -4,6 +4,29 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="25 mar 2026">
## v1.12.2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## O que Mudou
### Recursos
- Adicionar fase de lançamento empresarial ao lançamento do devtools
### Correções de Bugs
- Preservar o valor de retorno do método como saída de fluxo para @human_feedback com emit
### Documentação
- Atualizar changelog e versão para v1.12.1
- Revisar política de segurança e instruções de relatório
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 mar 2026">
## v1.12.1

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -11,7 +11,7 @@ dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.12.1",
"crewai==1.12.2",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -309,4 +309,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.12.1",
"crewai-tools==1.12.2",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -42,7 +42,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.12.1"
__version__ = "1.12.2"
_telemetry_submitted = False

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[tool.crewai]

View File

@@ -883,6 +883,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
self.human_feedback_history: list[HumanFeedbackResult] = []
self.last_human_feedback: HumanFeedbackResult | None = None
self._pending_feedback_context: PendingFeedbackContext | None = None
# Per-method stash for real @human_feedback output (keyed by method name)
# Used to decouple routing outcome from method return value when emit is set
self._human_feedback_method_outputs: dict[str, Any] = {}
self.suppress_flow_events: bool = suppress_flow_events
# User input history (for self.ask())
@@ -2290,6 +2293,17 @@ class Flow(Generic[T], metaclass=FlowMeta):
result = await result
self._method_outputs.append(result)
# For @human_feedback methods with emit, the result is the collapsed outcome
# (e.g., "approved") used for routing. But we want the actual method output
# to be the stored result (for final flow output). Replace the last entry
# if a stashed output exists. Dict-based stash is concurrency-safe and
# handles None return values (presence in dict = stashed, not value).
if method_name in self._human_feedback_method_outputs:
self._method_outputs[-1] = self._human_feedback_method_outputs.pop(
method_name
)
self._method_execution_counts[method_name] = (
self._method_execution_counts.get(method_name, 0) + 1
)

View File

@@ -591,6 +591,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper: Any = async_wrapper
@@ -615,6 +622,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper = sync_wrapper

View File

@@ -246,7 +246,7 @@ class TestHumanFeedbackExecution:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_with_default_outcome(self, mock_print, mock_input):
"""Test empty feedback uses default_outcome."""
"""Test empty feedback uses default_outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -264,14 +264,16 @@ class TestHumanFeedbackExecution:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "needs_work"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "needs_work"
@patch("builtins.input", return_value="Approved!")
@patch("builtins.print")
def test_feedback_collapsing(self, mock_print, mock_input):
"""Test that feedback is collapsed to an outcome."""
"""Test that feedback is collapsed to an outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -291,8 +293,10 @@ class TestHumanFeedbackExecution:
):
result = flow.kickoff()
assert result == "approved"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "approved"
@@ -591,3 +595,162 @@ class TestHumanFeedbackLearn:
assert config.learn is True
# llm defaults to "gpt-4o-mini" at the function level
assert config.llm == "gpt-4o-mini"
class TestHumanFeedbackFinalOutputPreservation:
"""Tests for preserving method return value as flow's final output when @human_feedback with emit is terminal.
This addresses the bug where the flow's final output was the collapsed outcome string (e.g., 'approved')
instead of the method's actual return value when a @human_feedback method with emit is the final method.
"""
@patch("builtins.input", return_value="Looks good!")
@patch("builtins.print")
def test_final_output_is_method_return_not_collapsed_outcome(
self, mock_print, mock_input
):
"""When @human_feedback with emit is the final method, flow output is the method's return value."""
class FinalHumanFeedbackFlow(Flow):
@start()
@human_feedback(
message="Review this content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate_and_review(self):
# This dict should be the final output, NOT the string 'approved'
return {"title": "My Article", "content": "Article content here", "status": "ready"}
flow = FinalHumanFeedbackFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="Looks great, approved!"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The final output should be the actual method return value, not the collapsed outcome
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"title": "My Article", "content": "Article content here", "status": "ready"}
# But the outcome should still be tracked in last_human_feedback
assert flow.last_human_feedback is not None
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="approved")
@patch("builtins.print")
def test_routing_still_works_with_downstream_listener(self, mock_print, mock_input):
"""When @human_feedback has a downstream listener, routing still triggers the listener."""
publish_called = []
class RoutingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return {"content": "original content"}
@listen("approved")
def publish(self):
publish_called.append(True)
return {"published": True, "timestamp": "2024-01-01"}
flow = RoutingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="LGTM"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The downstream listener should have been triggered
assert len(publish_called) == 1, "publish() should have been called"
# The final output should be from the listener, not the human_feedback method
assert result == {"published": True, "timestamp": "2024-01-01"}
@patch("builtins.input", return_value="")
@patch("builtins.print")
@pytest.mark.asyncio
async def test_async_human_feedback_final_output_preserved(self, mock_print, mock_input):
"""Async @human_feedback methods also preserve the real return value."""
class AsyncFinalFlow(Flow):
@start()
@human_feedback(
message="Review async content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
default_outcome="approved",
)
async def async_generate(self):
return {"async_data": "value", "computed": 42}
flow = AsyncFinalFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
):
result = await flow.kickoff_async()
# The final output should be the dict, not "approved"
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"async_data": "value", "computed": 42}
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")
def test_method_outputs_contains_real_output(self, mock_print, mock_input):
"""The _method_outputs list should contain the real method output, not the collapsed outcome."""
class OutputTrackingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate(self):
return {"data": "real output"}
flow = OutputTrackingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="approved"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
flow.kickoff()
# _method_outputs should contain the real output
assert len(flow._method_outputs) == 1
assert flow._method_outputs[0] == {"data": "real output"}
@patch("builtins.input", return_value="looks good")
@patch("builtins.print")
def test_none_return_value_is_preserved(self, mock_print, mock_input):
"""A method returning None should preserve None as flow output, not the outcome string."""
class NoneReturnFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def process(self):
# Method does work but returns None (implicit)
pass
flow = NoneReturnFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# Final output should be None (the method's real return), not "approved"
assert result is None, f"Expected None, got {result!r}"
assert flow.last_human_feedback.outcome == "approved"

View File

@@ -708,7 +708,7 @@ class TestEdgeCases:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_first_outcome_fallback(self, mock_print, mock_input):
"""Test that empty feedback without default uses first outcome."""
"""Test that empty feedback without default uses first outcome for routing, but returns method output."""
class FallbackFlow(Flow):
@start()
@@ -726,12 +726,15 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "first" # Falls back to first outcome
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is still set to first for routing purposes
assert flow.last_human_feedback.outcome == "first"
@patch("builtins.input", return_value="whitespace only ")
@patch("builtins.print")
def test_whitespace_only_feedback_treated_as_empty(self, mock_print, mock_input):
"""Test that whitespace-only feedback is treated as empty."""
"""Test that whitespace-only feedback is treated as empty for routing, but returns method output."""
class WhitespaceFlow(Flow):
@start()
@@ -749,7 +752,10 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=" "):
result = flow.kickoff()
assert result == "reject" # Uses default because feedback is empty after strip
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is set to default because feedback is empty after strip
assert flow.last_human_feedback.outcome == "reject"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")

View File

@@ -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 <version>`
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 <version>`

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -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 <version>[/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!")