mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-04-02 17:18:16 +00:00
Compare commits
4 Commits
1.12.1
...
docs/file-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ba5b823a | ||
|
|
5a850a708b | ||
|
|
bd03f6cf64 | ||
|
|
a91cd1a7d7 |
50
.github/security.md
vendored
50
.github/security.md
vendored
@@ -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
|
||||
|
||||
@@ -134,6 +134,29 @@ result = flow.kickoff(
|
||||
)
|
||||
```
|
||||
|
||||
You can also define file types directly in your flow state for structured file handling:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from crewai.flow.flow import Flow, start
|
||||
from crewai_files import ImageFile, PDFFile
|
||||
|
||||
class DocumentState(BaseModel):
|
||||
document: PDFFile
|
||||
cover_image: ImageFile
|
||||
title: str = ""
|
||||
|
||||
class DocumentFlow(Flow[DocumentState]):
|
||||
@start()
|
||||
def process(self):
|
||||
content = self.state.document.read()
|
||||
return {"processed": True}
|
||||
```
|
||||
|
||||
<Note type="info" title="CrewAI Platform Integration">
|
||||
When deploying flows to the CrewAI Platform (AMP), file fields in your state automatically render as file upload dropzones in the UI. For API usage, you can pass URL strings directly and Pydantic coerces them to file objects automatically. See [Flows - File Inputs](/en/concepts/flows#file-inputs) for details.
|
||||
</Note>
|
||||
|
||||
### With Standalone Agents
|
||||
|
||||
Pass files directly to agent kickoff:
|
||||
|
||||
@@ -341,6 +341,69 @@ flow.kickoff()
|
||||
|
||||
By providing both unstructured and structured state management options, CrewAI Flows empowers developers to build AI workflows that are both flexible and robust, catering to a wide range of application requirements.
|
||||
|
||||
## File Inputs
|
||||
|
||||
Flows support file inputs through the `crewai-files` package, enabling you to build workflows that process images, PDFs, and other file types. When you use file types like `ImageFile` or `PDFFile` in your flow state, they integrate seamlessly with both local development and the CrewAI Platform.
|
||||
|
||||
<Note type="info" title="Optional Dependency">
|
||||
File support requires the optional `crewai-files` package. Install it with:
|
||||
|
||||
```bash
|
||||
uv add 'crewai[file-processing]'
|
||||
```
|
||||
</Note>
|
||||
|
||||
### Using File Types in Flow State
|
||||
|
||||
You can include file types directly in your structured flow state:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from crewai.flow.flow import Flow, start
|
||||
from crewai_files import ImageFile, PDFFile
|
||||
|
||||
class DocumentProcessingState(BaseModel):
|
||||
document: PDFFile # Renders as file upload in CrewAI Platform
|
||||
cover_image: ImageFile # Renders as image upload
|
||||
title: str = "" # Renders as text input
|
||||
|
||||
class DocumentFlow(Flow[DocumentProcessingState]):
|
||||
@start()
|
||||
def process_document(self):
|
||||
# Access the file - works with URLs, paths, or uploaded files
|
||||
content = self.state.document.read()
|
||||
# Or pass to an agent with VisionTool, etc.
|
||||
return {"processed": True}
|
||||
```
|
||||
|
||||
### CrewAI Platform Integration
|
||||
|
||||
When you deploy a flow to the CrewAI Platform (AMP), file fields in your state automatically render as file upload dropzones in the UI. This makes it easy to build user-facing applications that accept file uploads without any additional frontend work.
|
||||
|
||||
| State Field Type | Platform UI Rendering |
|
||||
|:-----------------|:----------------------|
|
||||
| `ImageFile` | Image upload dropzone |
|
||||
| `PDFFile` | PDF upload dropzone |
|
||||
| `AudioFile` | Audio upload dropzone |
|
||||
| `VideoFile` | Video upload dropzone |
|
||||
| `TextFile` | Text file upload dropzone |
|
||||
| `str`, `int`, etc. | Standard form inputs |
|
||||
|
||||
### API Usage
|
||||
|
||||
When calling your flow via API, you can pass URL strings directly for file fields. Pydantic automatically coerces URLs into the appropriate file type:
|
||||
|
||||
```python
|
||||
# API request body - URLs are automatically converted to file objects
|
||||
{
|
||||
"document": "https://example.com/report.pdf",
|
||||
"cover_image": "https://example.com/cover.png",
|
||||
"title": "Q4 Report"
|
||||
}
|
||||
```
|
||||
|
||||
For more details on file types, sources, and provider support, see the [Files documentation](/en/concepts/files).
|
||||
|
||||
## Flow Persistence
|
||||
|
||||
The @persist decorator enables automatic state persistence in CrewAI Flows, allowing you to maintain flow state across restarts or different workflow executions. This decorator can be applied at either the class level or method level, providing flexibility in how you manage state persistence.
|
||||
|
||||
@@ -883,6 +883,7 @@ 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
|
||||
self._human_feedback_method_output: Any = None # Stashed real output from @human_feedback with emit
|
||||
self.suppress_flow_events: bool = suppress_flow_events
|
||||
|
||||
# User input history (for self.ask())
|
||||
@@ -2290,6 +2291,15 @@ 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.
|
||||
if self._human_feedback_method_output is not None:
|
||||
self._method_outputs[-1] = self._human_feedback_method_output
|
||||
self._human_feedback_method_output = None
|
||||
|
||||
self._method_execution_counts[method_name] = (
|
||||
self._method_execution_counts.get(method_name, 0) + 1
|
||||
)
|
||||
|
||||
@@ -591,6 +591,12 @@ 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)
|
||||
if emit:
|
||||
self._human_feedback_method_output = method_output
|
||||
|
||||
return result
|
||||
|
||||
wrapper: Any = async_wrapper
|
||||
@@ -615,6 +621,12 @@ 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)
|
||||
if emit:
|
||||
self._human_feedback_method_output = method_output
|
||||
|
||||
return result
|
||||
|
||||
wrapper = sync_wrapper
|
||||
|
||||
@@ -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,134 @@ 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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user