From 4ac290c88599c6cd45f04058942b1c49bd0461de Mon Sep 17 00:00:00 2001 From: Zach Wentz <4832+zkwentz@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:29:27 -0400 Subject: [PATCH] feat(cli): gate pushes on remote validation --- src/openenv/cli/commands/push.py | 191 +++++++++++++++-- src/openenv/validation/__init__.py | 2 + src/openenv/validation/remote.py | 10 +- tests/test_cli/test_push.py | 333 +++++++++++++++++++++++++++-- 4 files changed, 494 insertions(+), 42 deletions(-) diff --git a/src/openenv/cli/commands/push.py b/src/openenv/cli/commands/push.py index e40aee24c..7d5759221 100644 --- a/src/openenv/cli/commands/push.py +++ b/src/openenv/cli/commands/push.py @@ -4,23 +4,50 @@ from __future__ import annotations +import json import shutil import sys import tempfile from fnmatch import fnmatch from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer import yaml from huggingface_hub import HfApi, login, whoami - -from .._cli_utils import _extract_hf_username, console, validate_env_structure +from openenv.validation import ( + format_shared_validation_report, + RemoteValidationError, + run_local_validation, + run_remote_validation, + validation_source_digest, + validation_source_path_allowed, + ValidationProfile, + ValidationReport, + ValidationSeverity, + ValidationStatus, +) + +from .._cli_utils import _extract_hf_username, console app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces") -DEFAULT_PUSH_IGNORE_PATTERNS = [".*", "__pycache__", "*.pyc"] +DEFAULT_PUSH_IGNORE_PATTERNS = [ + ".env", + ".env.*", + ".git/", + ".hg/", + ".mypy_cache/", + ".pytest_cache/", + ".ruff_cache/", + ".svn/", + ".tox/", + ".venv/", + "__pycache__/", + "*.pyc", +] +_VERSIONED_VALIDATION_REPORT = Path(".openenv/validation-report.json") def _format_kv_entry_for_error(entry: str, *, flag: str) -> str: @@ -127,6 +154,20 @@ def _should_exclude_path(relative_path: Path, ignore_patterns: list[str]) -> boo ) +def _excludes_versioned_report(ignore_patterns: list[str]) -> bool: + """Return whether an upload pattern can omit the required author report.""" + required_paths = ( + _VERSIONED_VALIDATION_REPORT, + *_VERSIONED_VALIDATION_REPORT.parents, + ) + return any( + _path_matches_pattern(required_path, pattern) + for pattern in ignore_patterns + for required_path in required_paths + if required_path != Path(".") + ) + + def _read_ignore_file(ignore_path: Path) -> tuple[list[str], int]: """Read ignore patterns from a file and return (patterns, ignored_negations).""" patterns: list[str] = [] @@ -198,8 +239,15 @@ def _ignore(path: str, names: list[str]) -> set[str]: # candidate is not under env_dir (e.g. symlink or # copytree root differs from env_dir); skip filtering. continue - if _should_exclude_path(relative_path, ignore_patterns): + if _should_exclude_path( + relative_path, ignore_patterns + ) or not validation_source_path_allowed(relative_path): ignored.add(name) + elif candidate.is_symlink(): + raise typer.BadParameter( + "Source contains a symbolic link that would enter the " + f"validation snapshot: {relative_path}" + ) return ignored @@ -207,26 +255,16 @@ def _ignore(path: str, names: list[str]) -> set[str]: def _validate_openenv_directory(directory: Path) -> tuple[str, dict]: - """ - Validate that the directory is an OpenEnv environment. + """Load the minimal manifest identity needed before shared validation. Returns: `tuple` of `(env_name, manifest_data)`. """ - # Use the comprehensive validation function - try: - warnings = validate_env_structure(directory) - for warning in warnings: - console.print(f"[bold yellow]⚠[/bold yellow] {warning}") - except FileNotFoundError as e: - raise typer.BadParameter(f"Invalid OpenEnv environment structure: {e}") from e - - # Load and validate manifest manifest_path = directory / "openenv.yaml" try: - with open(manifest_path, "r") as f: + with manifest_path.open(encoding="utf-8") as f: manifest = yaml.safe_load(f) - except Exception as e: + except (OSError, yaml.YAMLError) as e: raise typer.BadParameter(f"Failed to parse openenv.yaml: {e}") from e if not isinstance(manifest, dict): @@ -239,6 +277,86 @@ def _validate_openenv_directory(directory: Path) -> tuple[str, dict]: return env_name, manifest +def _run_publish_validation( + directory: Path, *, remote: bool = True +) -> ValidationReport: + """Run the shared strict publish profile locally or in an HF Sandbox.""" + if remote: + return run_remote_validation(directory, profile=ValidationProfile.PUBLISH) + return run_local_validation(directory, profile=ValidationProfile.PUBLISH) + + +def _render_publish_gate(report: ValidationReport) -> None: + """Render author guidance and stop when blocking criteria are not satisfied.""" + console.print(format_shared_validation_report(report), markup=False) + if report.passed: + console.print("[bold green]✓[/bold green] Publish validation passed") + return + + blocking_skip = any( + result.severity is ValidationSeverity.BLOCKING + and result.status is ValidationStatus.SKIP + for result in report.results + ) + outcome = "incomplete" if blocking_skip else "failed" + console.print( + f"[bold red]✗[/bold red] Publish validation {outcome}; upload was not attempted" + ) + raise typer.Exit(1) + + +def _portable_report_value(value: Any, source_root: Path) -> Any: + """Remove machine- and Sandbox-specific source roots from a report payload.""" + if isinstance(value, dict): + return { + key: _portable_report_value(item, source_root) + for key, item in value.items() + } + if isinstance(value, list): + return [_portable_report_value(item, source_root) for item in value] + if isinstance(value, str): + local_root = str(source_root.resolve()) + portable = value.replace(f"{local_root}/", "./") + portable = "." if portable == local_root else portable + portable = portable.replace("/workspace/source/", "./") + return "." if portable == "/workspace/source" else portable + return value + + +def _write_versioned_validation_report( + staging_dir: Path, report: ValidationReport +) -> None: + """Write the portable, explicitly non-certified author report into the upload.""" + payload = _portable_report_value(report.to_dict(), staging_dir) + payload["target"] = "." + payload["certified"] = False + payload["certification_eligible"] = False + report_path = staging_dir / _VERSIONED_VALIDATION_REPORT + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + f"{json.dumps(payload, indent=2, sort_keys=True)}\n", + encoding="utf-8", + ) + + +def _verify_snapshot_binding(staging_dir: Path, report: ValidationReport) -> None: + """Require the report digest to match the exact tree about to be uploaded.""" + try: + actual_digest = validation_source_digest(staging_dir) + except RemoteValidationError as exc: + raise typer.BadParameter(str(exc)) from exc + if report.source_digest is None: + console.print( + "[bold red]✗[/bold red] Publish validation report is missing its source digest" + ) + raise typer.Exit(1) + if report.source_digest != actual_digest: + console.print( + "[bold red]✗[/bold red] The staged environment changed after validation; upload was not attempted" + ) + raise typer.Exit(1) + + def _get_hf_username() -> str: """Return the authenticated Hugging Face username from whoami().""" username = _extract_hf_username(whoami()) @@ -292,12 +410,20 @@ def _prepare_staging_directory( # Create staging directory structure staging_dir.mkdir(parents=True, exist_ok=True) - # Copy all files from env directory + # Use the same file policy as the validation archive so every uploaded + # source file is covered by the report's source digest. copy_ignore = _copytree_ignore_factory(env_dir, ignore_patterns) for item in env_dir.iterdir(): relative_path = item.relative_to(env_dir) - if _should_exclude_path(relative_path, ignore_patterns): + if _should_exclude_path( + relative_path, ignore_patterns + ) or not validation_source_path_allowed(relative_path): continue + if item.is_symlink(): + raise typer.BadParameter( + "Source contains a symbolic link that would enter the " + f"validation snapshot: {relative_path}" + ) dest = staging_dir / item.name if item.is_dir(): @@ -734,6 +860,13 @@ def push( # Handle custom registry push if registry: + try: + validation_report = _run_publish_validation(env_dir, remote=False) + except (RemoteValidationError, ValueError) as exc: + console.print(f"[bold red]✗[/bold red] Publish validation failed: {exc}") + raise typer.Exit(1) from exc + _render_publish_gate(validation_report) + console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]") if enable_interface: console.print("[bold cyan]Web interface will be enabled[/bold cyan]") @@ -778,6 +911,11 @@ def push( return ignore_patterns = _load_ignore_patterns(env_dir, exclude) + if _excludes_versioned_report(ignore_patterns): + raise typer.BadParameter( + "Push excludes must preserve the versioned author report at " + f"{_VERSIONED_VALIDATION_REPORT.as_posix()}" + ) # Ensure authentication for HuggingFace username = _ensure_hf_authenticated() @@ -814,6 +952,19 @@ def push( enable_interface=enable_interface, ) + console.print( + "[bold cyan]Running strict publish validation in a dedicated Hugging Face Sandbox...[/bold cyan]" + ) + try: + validation_report = _run_publish_validation(staging_dir) + except (RemoteValidationError, ValueError) as exc: + console.print(f"[bold red]✗[/bold red] Remote validation failed: {exc}") + raise typer.Exit(1) from exc + _render_publish_gate(validation_report) + _verify_snapshot_binding(staging_dir, validation_report) + _write_versioned_validation_report(staging_dir, validation_report) + _verify_snapshot_binding(staging_dir, validation_report) + if count > 1: base_repo_id = repo_id for i in range(1, count + 1): diff --git a/src/openenv/validation/__init__.py b/src/openenv/validation/__init__.py index b8c5edb12..3b292436b 100644 --- a/src/openenv/validation/__init__.py +++ b/src/openenv/validation/__init__.py @@ -31,6 +31,7 @@ RemoteValidationError, run_remote_validation, validation_source_digest, + validation_source_path_allowed, ) from .security import ensure_official_hf_sandbox @@ -61,4 +62,5 @@ "run_local_validation", "run_remote_validation", "validation_source_digest", + "validation_source_path_allowed", ] diff --git a/src/openenv/validation/remote.py b/src/openenv/validation/remote.py index c81aa4fbc..a9dfe2815 100644 --- a/src/openenv/validation/remote.py +++ b/src/openenv/validation/remote.py @@ -86,7 +86,11 @@ class RemoteValidationError(RuntimeError): """Remote author validation could not produce a trustworthy report payload.""" -def _archive_path_allowed(relative: Path) -> bool: +def validation_source_path_allowed(relative: str | Path) -> bool: + """Return whether a relative file path belongs in a validation snapshot.""" + relative = Path(relative) + if relative.is_absolute() or ".." in relative.parts or not relative.parts: + return False if any(part in _EXCLUDED_PARTS or part.startswith(".") for part in relative.parts): return False name = relative.name @@ -119,7 +123,7 @@ def validation_source_digest(root: str | Path) -> str: digest = hashlib.sha256() for candidate in sorted(root_path.rglob("*")): relative = candidate.relative_to(root_path) - if not _archive_path_allowed(relative) or not candidate.is_file(): + if not validation_source_path_allowed(relative) or not candidate.is_file(): continue encoded_path = relative.as_posix().encode("utf-8") digest.update(len(encoded_path).to_bytes(8, "big")) @@ -151,7 +155,7 @@ def normalize(info: tarfile.TarInfo) -> tarfile.TarInfo: for candidate in sorted(root.rglob("*")): relative = candidate.relative_to(root) if ( - not _archive_path_allowed(relative) + not validation_source_path_allowed(relative) or candidate.is_symlink() or not (candidate.is_file() or candidate.is_dir()) ): diff --git a/tests/test_cli/test_push.py b/tests/test_cli/test_push.py index e6893947a..90aa14fcd 100644 --- a/tests/test_cli/test_push.py +++ b/tests/test_cli/test_push.py @@ -2,13 +2,24 @@ """Tests for the openenv push command.""" +import json import os import re from pathlib import Path from unittest.mock import MagicMock, patch +import pytest import yaml from openenv.cli.__main__ import app +from openenv.validation import ( + RunnerCapabilities, + validation_source_digest, + ValidationProfile, + ValidationReport, + ValidationResult, + ValidationSeverity, + ValidationStatus, +) from typer.testing import CliRunner @@ -21,6 +32,56 @@ def _strip_ansi(text: str) -> str: return ANSI_ESCAPE_RE.sub("", text) +def _publish_validation_report( + *, + target: str = ".", + status: ValidationStatus = ValidationStatus.PASS, + criterion_id: str = "source.publish_ready", + message: str | None = None, + evidence: dict[str, object] | None = None, + source_digest: str | None = None, +) -> ValidationReport: + """Build a small shared report without launching an environment.""" + return ValidationReport( + target=target, + profile=ValidationProfile.PUBLISH, + policy_version="rfc008-v1", + runner=RunnerCapabilities(runner="hf-sandbox", isolation_mode="dedicated"), + results=( + ValidationResult( + criterion_id=criterion_id, + requirement="The environment satisfies strict publish validation", + status=status, + severity=ValidationSeverity.BLOCKING, + evidence=evidence or {}, + duration_s=0.01, + timeout_s=5.0, + required_capabilities=frozenset(), + message=message, + ), + ), + duration_s=0.01, + started_at="2026-07-12T12:00:00+00:00", + finished_at="2026-07-12T12:00:00.010000+00:00", + source_digest=source_digest, + ) + + +@pytest.fixture(autouse=True) +def _mock_publish_validation(): + """Keep push tests isolated from the strict remote validator.""" + + def passing(source: Path, **_: object) -> ValidationReport: + return _publish_validation_report( + target=str(source), + source_digest=validation_source_digest(source), + ) + + with patch("openenv.cli.commands.push._run_publish_validation") as validation: + validation.side_effect = passing + yield validation + + def _create_test_openenv_env(env_dir: Path, env_name: str = "test_env") -> None: """Create a complete OpenEnv environment for testing.""" # Create openenv.yaml @@ -470,18 +531,27 @@ def _capture_staging(*, folder_path: str, **_: object) -> None: assert "server/Dockerfile" not in files -def test_push_handles_missing_dockerfile(tmp_path: Path) -> None: +def test_push_handles_missing_dockerfile( + tmp_path: Path, _mock_publish_validation: MagicMock +) -> None: """Test that push fails when Dockerfile is missing (required for deployment).""" _create_test_openenv_env(tmp_path) # Remove Dockerfile (no root Dockerfile either) (tmp_path / "server" / "Dockerfile").unlink() + _mock_publish_validation.side_effect = None + _mock_publish_validation.return_value = _publish_validation_report( + status=ValidationStatus.FAIL, + criterion_id="source.dockerfile", + message="Missing Dockerfile and normalized container-image declaration", + ) - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - result = runner.invoke(app, ["push"]) - finally: - os.chdir(old_cwd) + with patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}): + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + result = runner.invoke(app, ["push"]) + finally: + os.chdir(old_cwd) # Dockerfile is now required - should fail assert result.exit_code != 0 @@ -489,21 +559,27 @@ def test_push_handles_missing_dockerfile(tmp_path: Path) -> None: def test_push_handles_missing_readme(tmp_path: Path) -> None: - """Test that push fails when README.md is missing (required for deployment).""" + """A missing optional README warns without bypassing publish validation.""" _create_test_openenv_env(tmp_path) # Remove README (tmp_path / "README.md").unlink() - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - result = runner.invoke(app, ["push"]) - finally: - os.chdir(old_cwd) + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): + mock_api = MagicMock() + mock_hf_api_class.return_value = mock_api + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + result = runner.invoke(app, ["push"]) + finally: + os.chdir(old_cwd) - # README.md is now required - should fail - assert result.exit_code != 0 - assert "readme" in result.output.lower() or "missing" in result.output.lower() + assert result.exit_code == 0, result.output + assert "readme" in result.output.lower() + assert mock_api.upload_folder.called def test_push_initializes_hf_api_without_token(tmp_path: Path) -> None: @@ -568,7 +644,6 @@ def test_push_bare_repo_id_expands_to_username(tmp_path: Path) -> None: patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, patch("openenv.cli.commands.push._upload_to_hf_space") as mock_upload, patch("openenv.cli.commands.push._create_hf_space") as mock_create, - patch("openenv.cli.commands.push._prepare_staging_directory") as _mock_stage, ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None @@ -842,7 +917,9 @@ def _assert_upload_payload(*_unused_args, **kwargs): ignore_patterns = kwargs["ignore_patterns"] assert "excluded_dir/" in ignore_patterns assert "*.bin" in ignore_patterns - assert ".*" in ignore_patterns + assert ".git/" in ignore_patterns + assert ".env" in ignore_patterns + assert ".*" not in ignore_patterns staged = Path(kwargs["folder_path"]) assert not (staged / "excluded_dir").exists() @@ -1588,3 +1665,221 @@ def test_push_exits_cleanly_if_setting_space_variable_fails(tmp_path: Path) -> N assert "failed to set variable openspiel_game" in result.output.lower() assert "permission denied" in result.output.lower() assert "traceback" not in result.output.lower() + + +def test_push_validates_staged_snapshot_before_hf_upload( + tmp_path: Path, _mock_publish_validation: MagicMock +) -> None: + _create_test_openenv_env(tmp_path) + (tmp_path / "excluded.txt").write_text("not uploaded\n") + ignore_file = tmp_path / ".openenvignore" + ignore_file.write_text("excluded.txt\n") + events: list[str] = [] + + def validate_staging(source: Path, **_: object) -> ValidationReport: + events.append("validate") + assert source.name == "staging" + assert (source / "Dockerfile").is_file() + assert not (source / "server" / "Dockerfile").exists() + assert not (source / "excluded.txt").exists() + return _publish_validation_report( + target=str(source), + source_digest=validation_source_digest(source), + ) + + def upload(**_: object) -> None: + events.append("upload") + + _mock_publish_validation.side_effect = validate_staging + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as api_class, + ): + api = MagicMock() + api.upload_folder.side_effect = upload + api_class.return_value = api + result = runner.invoke( + app, + ["push", str(tmp_path), "--exclude", str(ignore_file)], + ) + + assert result.exit_code == 0, result.output + assert events == ["validate", "upload"] + + +def test_push_stages_safe_versioned_validation_report_for_hf( + tmp_path: Path, _mock_publish_validation: MagicMock +) -> None: + _create_test_openenv_env(tmp_path) + (tmp_path / ".envrc").write_text("export HF_TOKEN=do-not-upload\n") + (tmp_path / ".netrc").write_text("password do-not-upload\n") + (tmp_path / "credentials.json").write_text('{"token": "do-not-upload"}\n') + (tmp_path / "validation-report.json").write_text("{}\n") + (tmp_path / "SECRET.PEM").write_text("do-not-upload\n") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "payload.js").write_text("unchecked\n") + outside = tmp_path.parent / "excluded-venv-python" + outside.write_text("do-not-upload\n") + (tmp_path / ".venv" / "bin").mkdir(parents=True) + (tmp_path / ".venv" / "bin" / "python").symlink_to(outside) + captured: dict[str, object] = {} + + def validate_staging(source: Path, **_: object) -> ValidationReport: + assert not (source / ".envrc").exists() + assert not (source / ".netrc").exists() + assert not (source / "credentials.json").exists() + assert not (source / "validation-report.json").exists() + assert not (source / "SECRET.PEM").exists() + assert not (source / "node_modules").exists() + assert not (source / ".venv").exists() + return _publish_validation_report( + target=str(source), + evidence={ + "source_path": f"{source}/server/app.py", + "sandbox_path": "/workspace/source/openenv.yaml", + }, + source_digest=validation_source_digest(source), + ) + + def capture_upload(*, folder_path: str, ignore_patterns: list[str], **_: object): + staging = Path(folder_path) + report_path = staging / ".openenv" / "validation-report.json" + captured["payload"] = json.loads(report_path.read_text()) + captured["ignore_patterns"] = ignore_patterns + + _mock_publish_validation.side_effect = validate_staging + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as api_class, + ): + api = MagicMock() + api.upload_folder.side_effect = capture_upload + api_class.return_value = api + result = runner.invoke(app, ["push", str(tmp_path)]) + + assert result.exit_code == 0, result.output + payload = captured["payload"] + assert isinstance(payload, dict) + assert payload["report_schema_version"] == "1.0" + assert payload["policy_version"] == "rfc008-v1" + assert payload["target"] == "." + assert payload["source_digest"].startswith("sha256:") + assert "/workspace/source" not in json.dumps(payload) + assert payload["certified"] is False + assert ".*" not in captured["ignore_patterns"] + + +@pytest.mark.parametrize( + "pattern", + [".*", ".openenv", ".openenv/", ".openenv/*", "validation-report.json"], +) +def test_push_rejects_excludes_that_drop_versioned_validation_report( + tmp_path: Path, pattern: str +) -> None: + _create_test_openenv_env(tmp_path) + ignore_file = tmp_path / ".openenvignore" + ignore_file.write_text(f"{pattern}\n") + + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as api_class, + ): + api = MagicMock() + api_class.return_value = api + result = runner.invoke( + app, + ["push", str(tmp_path), "--exclude", str(ignore_file)], + ) + + assert result.exit_code != 0 + assert ".openenv/validation-report.json" in _strip_ansi(result.output) + api.upload_folder.assert_not_called() + + +def test_push_rejects_source_symlinks(tmp_path: Path) -> None: + _create_test_openenv_env(tmp_path) + outside = tmp_path.parent / "outside-secret.txt" + outside.write_text("do-not-upload\n") + (tmp_path / "linked-secret.txt").symlink_to(outside) + + with patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}): + result = runner.invoke(app, ["push", str(tmp_path)]) + + assert result.exit_code != 0 + assert "symbolic link" in _strip_ansi(result.output).lower() + + +def test_push_rejects_snapshot_changed_after_validation( + tmp_path: Path, _mock_publish_validation: MagicMock +) -> None: + _create_test_openenv_env(tmp_path) + + def mutate_after_validation(source: Path, **_: object) -> ValidationReport: + digest = validation_source_digest(source) + (source / "server" / "app.py").write_text("tampered\n") + return _publish_validation_report( + target=str(source), + source_digest=digest, + ) + + _mock_publish_validation.side_effect = mutate_after_validation + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as api_class, + ): + api = MagicMock() + api_class.return_value = api + result = runner.invoke(app, ["push", str(tmp_path)]) + + assert result.exit_code != 0 + assert "changed after validation" in _strip_ansi(result.output).lower() + api.upload_folder.assert_not_called() + + +@pytest.mark.parametrize( + ("status", "criterion_id", "message", "actionable_word"), + [ + ( + ValidationStatus.FAIL, + "source.dependencies", + "Missing required dependency: openenv>=0.2.0", + "failed", + ), + ( + ValidationStatus.SKIP, + "runtime.websocket", + "The strict runtime check did not run", + "incomplete", + ), + ], +) +def test_push_rejects_failed_or_incomplete_validation_before_upload( + tmp_path: Path, + _mock_publish_validation: MagicMock, + status: ValidationStatus, + criterion_id: str, + message: str, + actionable_word: str, +) -> None: + _create_test_openenv_env(tmp_path) + _mock_publish_validation.side_effect = None + _mock_publish_validation.return_value = _publish_validation_report( + status=status, + criterion_id=criterion_id, + message=message, + ) + + with ( + patch("openenv.cli.commands.push.whoami", return_value={"name": "testuser"}), + patch("openenv.cli.commands.push.HfApi") as api_class, + ): + api = MagicMock() + api_class.return_value = api + result = runner.invoke(app, ["push", str(tmp_path)]) + + clean_output = _strip_ansi(result.output).lower() + assert result.exit_code != 0 + api.upload_folder.assert_not_called() + assert actionable_word in clean_output + assert criterion_id in clean_output + assert message.lower() in clean_output