From a722f105d428816c930f6e3197a7b86ba2e245bd Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Mon, 14 Sep 2026 09:00:18 -0700 Subject: [PATCH] fix(cli): treat staging and temp-file failures as archive errors `create_project_zip` only wrapped the ZIP write, so an `OSError` while staging files or creating the temporary archive escaped as a bare `OSError` and the deploy command recorded it as `unexpected` instead of `zip_error`. The archive boundary now covers staging, temp-file creation and the write; the staging directory is removed on every path, and no partial archive is left behind. Co-Authored-By: Claude Fable 5.1 --- lib/cli/src/crewai_cli/deploy/archive.py | 51 +++++++++++++++--------- lib/cli/tests/deploy/test_archive.py | 30 ++++++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py index 760058003..04aef19e7 100644 --- a/lib/cli/src/crewai_cli/deploy/archive.py +++ b/lib/cli/src/crewai_cli/deploy/archive.py @@ -51,33 +51,25 @@ def create_project_zip( project_dir: Path | None = None, repository: git.Repository | None = None, ) -> Path: - """Create a deployable ZIP archive for a CrewAI project.""" + """Create a deployable ZIP archive for a CrewAI project. + + Raises: + ArchiveError: Nothing deployable was found, or the project could not be + staged or written to the archive. No partial archive is left behind. + """ root = (project_dir or Path.cwd()).resolve() files = _project_files(root, repository) if not files: raise ArchiveError("No deployable project files were found.") - staged_root = _stage_project(root, files) - archive_handle = tempfile.NamedTemporaryFile( - prefix=f"{project_name}-", - suffix=".zip", - delete=False, - ) - archive_path = Path(archive_handle.name) - archive_handle.close() - try: - with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file: - for relative_path in _walk_files(staged_root): - absolute_path = staged_root / relative_path - zip_file.write(absolute_path, relative_path.as_posix()) + staged_root = _stage_project(root, files) + try: + return _write_archive(project_name, staged_root) + finally: + shutil.rmtree(staged_root, ignore_errors=True) except (OSError, zipfile.BadZipFile) as exc: - archive_path.unlink(missing_ok=True) raise ArchiveError(f"Could not build the project ZIP: {exc}") from exc - finally: - shutil.rmtree(staged_root, ignore_errors=True) - - return archive_path def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]: @@ -153,3 +145,24 @@ def _stage_project(root: Path, files: list[Path]) -> Path: shutil.rmtree(staging_root, ignore_errors=True) raise return staging_root + + +def _write_archive(project_name: str, staged_root: Path) -> Path: + """Zip the staged tree into a new temporary file, removing it if the write fails.""" + archive_handle = tempfile.NamedTemporaryFile( + prefix=f"{project_name}-", + suffix=".zip", + delete=False, + ) + archive_path = Path(archive_handle.name) + archive_handle.close() + + try: + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file: + for relative_path in _walk_files(staged_root): + absolute_path = staged_root / relative_path + zip_file.write(absolute_path, relative_path.as_posix()) + except (OSError, zipfile.BadZipFile): + archive_path.unlink(missing_ok=True) + raise + return archive_path diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index 38327624a..5f73605fe 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -1,5 +1,6 @@ from pathlib import Path import subprocess +import tempfile import zipfile import pytest @@ -333,3 +334,32 @@ def test_create_project_zip_wraps_a_write_failure_and_removes_the_partial_file( create_project_zip("demo", project_dir=tmp_path) assert created and not created[0].exists() + + +@pytest.mark.parametrize( + "failing_step", + [ + "crewai_cli.deploy.archive.shutil.copy2", + "crewai_cli.deploy.archive.tempfile.NamedTemporaryFile", + ], +) +def test_create_project_zip_wraps_staging_and_temp_file_failures_and_cleans_up( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failing_step: str +): + """A full disk while staging or creating the archive is an archive failure too.""" + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + scratch = tmp_path / "scratch" + scratch.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(scratch)) + + def fail(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(failing_step, fail) + + with pytest.raises(ArchiveError, match="Could not build the project ZIP: disk full"): + create_project_zip("demo", project_dir=project) + + assert list(scratch.iterdir()) == []