44
55from __future__ import annotations
66
7+ import json
78import shutil
89import sys
910import tempfile
1011from fnmatch import fnmatch
1112from pathlib import Path
12- from typing import Annotated
13+ from typing import Annotated , Any
1314
1415import typer
1516import yaml
1617from huggingface_hub import HfApi , login , whoami
17-
18- from .._cli_utils import _extract_hf_username , console , validate_env_structure
18+ from openenv .validation import (
19+ format_shared_validation_report ,
20+ RemoteValidationError ,
21+ run_local_validation ,
22+ run_remote_validation ,
23+ validation_source_digest ,
24+ validation_source_path_allowed ,
25+ ValidationProfile ,
26+ ValidationReport ,
27+ ValidationSeverity ,
28+ ValidationStatus ,
29+ )
30+
31+ from .._cli_utils import _extract_hf_username , console
1932
2033app = typer .Typer (help = "Push an OpenEnv environment to Hugging Face Spaces" )
2134
2235
23- DEFAULT_PUSH_IGNORE_PATTERNS = [".*" , "__pycache__" , "*.pyc" ]
36+ DEFAULT_PUSH_IGNORE_PATTERNS = [
37+ ".env" ,
38+ ".env.*" ,
39+ ".git/" ,
40+ ".hg/" ,
41+ ".mypy_cache/" ,
42+ ".pytest_cache/" ,
43+ ".ruff_cache/" ,
44+ ".svn/" ,
45+ ".tox/" ,
46+ ".venv/" ,
47+ "__pycache__/" ,
48+ "*.pyc" ,
49+ ]
50+ _VERSIONED_VALIDATION_REPORT = Path (".openenv/validation-report.json" )
2451
2552
2653def _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
127154 )
128155
129156
157+ def _excludes_versioned_report (ignore_patterns : list [str ]) -> bool :
158+ """Return whether an upload pattern can omit the required author report."""
159+ required_paths = (
160+ _VERSIONED_VALIDATION_REPORT ,
161+ * _VERSIONED_VALIDATION_REPORT .parents ,
162+ )
163+ return any (
164+ _path_matches_pattern (required_path , pattern )
165+ for pattern in ignore_patterns
166+ for required_path in required_paths
167+ if required_path != Path ("." )
168+ )
169+
170+
130171def _read_ignore_file (ignore_path : Path ) -> tuple [list [str ], int ]:
131172 """Read ignore patterns from a file and return (patterns, ignored_negations)."""
132173 patterns : list [str ] = []
@@ -198,7 +239,9 @@ def _ignore(path: str, names: list[str]) -> set[str]:
198239 # candidate is not under env_dir (e.g. symlink or
199240 # copytree root differs from env_dir); skip filtering.
200241 continue
201- if _should_exclude_path (relative_path , ignore_patterns ):
242+ if _should_exclude_path (
243+ relative_path , ignore_patterns
244+ ) or not validation_source_path_allowed (relative_path ):
202245 ignored .add (name )
203246
204247 return ignored
@@ -207,26 +250,16 @@ def _ignore(path: str, names: list[str]) -> set[str]:
207250
208251
209252def _validate_openenv_directory (directory : Path ) -> tuple [str , dict ]:
210- """
211- Validate that the directory is an OpenEnv environment.
253+ """Load the minimal manifest identity needed before shared validation.
212254
213255 Returns:
214256 `tuple` of `(env_name, manifest_data)`.
215257 """
216- # Use the comprehensive validation function
217- try :
218- warnings = validate_env_structure (directory )
219- for warning in warnings :
220- console .print (f"[bold yellow]⚠[/bold yellow] { warning } " )
221- except FileNotFoundError as e :
222- raise typer .BadParameter (f"Invalid OpenEnv environment structure: { e } " ) from e
223-
224- # Load and validate manifest
225258 manifest_path = directory / "openenv.yaml"
226259 try :
227- with open (manifest_path , "r " ) as f :
260+ with manifest_path . open (encoding = "utf-8 " ) as f :
228261 manifest = yaml .safe_load (f )
229- except Exception as e :
262+ except ( OSError , yaml . YAMLError ) as e :
230263 raise typer .BadParameter (f"Failed to parse openenv.yaml: { e } " ) from e
231264
232265 if not isinstance (manifest , dict ):
@@ -239,6 +272,96 @@ def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:
239272 return env_name , manifest
240273
241274
275+ def _run_publish_validation (
276+ directory : Path , * , remote : bool = True
277+ ) -> ValidationReport :
278+ """Run the shared strict publish profile locally or in an HF Sandbox."""
279+ if remote :
280+ return run_remote_validation (directory , profile = ValidationProfile .PUBLISH )
281+ return run_local_validation (directory , profile = ValidationProfile .PUBLISH )
282+
283+
284+ def _render_publish_gate (report : ValidationReport ) -> None :
285+ """Render author guidance and stop when blocking criteria are not satisfied."""
286+ console .print (format_shared_validation_report (report ), markup = False )
287+ if report .passed :
288+ console .print ("[bold green]✓[/bold green] Publish validation passed" )
289+ return
290+
291+ blocking_skip = any (
292+ result .severity is ValidationSeverity .BLOCKING
293+ and result .status is ValidationStatus .SKIP
294+ for result in report .results
295+ )
296+ outcome = "incomplete" if blocking_skip else "failed"
297+ console .print (
298+ f"[bold red]✗[/bold red] Publish validation { outcome } ; upload was not attempted"
299+ )
300+ raise typer .Exit (1 )
301+
302+
303+ def _portable_report_value (value : Any , source_root : Path ) -> Any :
304+ """Remove machine- and Sandbox-specific source roots from a report payload."""
305+ if isinstance (value , dict ):
306+ return {
307+ key : _portable_report_value (item , source_root )
308+ for key , item in value .items ()
309+ }
310+ if isinstance (value , list ):
311+ return [_portable_report_value (item , source_root ) for item in value ]
312+ if isinstance (value , str ):
313+ local_root = str (source_root .resolve ())
314+ portable = value .replace (f"{ local_root } /" , "./" )
315+ portable = "." if portable == local_root else portable
316+ portable = portable .replace ("/workspace/source/" , "./" )
317+ return "." if portable == "/workspace/source" else portable
318+ return value
319+
320+
321+ def _write_versioned_validation_report (
322+ staging_dir : Path , report : ValidationReport
323+ ) -> None :
324+ """Write the portable, explicitly non-certified author report into the upload."""
325+ payload = _portable_report_value (report .to_dict (), staging_dir )
326+ payload ["target" ] = "."
327+ payload ["certified" ] = False
328+ payload ["certification_eligible" ] = False
329+ report_path = staging_dir / _VERSIONED_VALIDATION_REPORT
330+ report_path .parent .mkdir (parents = True , exist_ok = True )
331+ report_path .write_text (
332+ f"{ json .dumps (payload , indent = 2 , sort_keys = True )} \n " ,
333+ encoding = "utf-8" ,
334+ )
335+
336+
337+ def _verify_snapshot_binding (staging_dir : Path , report : ValidationReport ) -> None :
338+ """Require the report digest to match the exact tree about to be uploaded."""
339+ try :
340+ actual_digest = validation_source_digest (staging_dir )
341+ except RemoteValidationError as exc :
342+ raise typer .BadParameter (str (exc )) from exc
343+ if report .source_digest is None :
344+ console .print (
345+ "[bold red]✗[/bold red] Publish validation report is missing its source digest"
346+ )
347+ raise typer .Exit (1 )
348+ if report .source_digest != actual_digest :
349+ console .print (
350+ "[bold red]✗[/bold red] The staged environment changed after validation; upload was not attempted"
351+ )
352+ raise typer .Exit (1 )
353+
354+
355+ def _validate_copy_source (env_dir : Path ) -> None :
356+ """Reject links so staging cannot copy content outside the source tree."""
357+ for candidate in env_dir .rglob ("*" ):
358+ if candidate .is_symlink ():
359+ relative = candidate .relative_to (env_dir )
360+ raise typer .BadParameter (
361+ f"Source contains a symbolic link, which push does not follow: { relative } "
362+ )
363+
364+
242365def _get_hf_username () -> str :
243366 """Return the authenticated Hugging Face username from whoami()."""
244367 username = _extract_hf_username (whoami ())
@@ -289,14 +412,19 @@ def _prepare_staging_directory(
289412 - Modifying Dockerfile to optionally enable web interface and update base image
290413 - Ensuring README has proper HF frontmatter (if interface enabled)
291414 """
415+ _validate_copy_source (env_dir )
416+
292417 # Create staging directory structure
293418 staging_dir .mkdir (parents = True , exist_ok = True )
294419
295- # Copy all files from env directory
420+ # Use the same file policy as the validation archive so every uploaded
421+ # source file is covered by the report's source digest.
296422 copy_ignore = _copytree_ignore_factory (env_dir , ignore_patterns )
297423 for item in env_dir .iterdir ():
298424 relative_path = item .relative_to (env_dir )
299- if _should_exclude_path (relative_path , ignore_patterns ):
425+ if _should_exclude_path (
426+ relative_path , ignore_patterns
427+ ) or not validation_source_path_allowed (relative_path ):
300428 continue
301429
302430 dest = staging_dir / item .name
@@ -734,6 +862,13 @@ def push(
734862
735863 # Handle custom registry push
736864 if registry :
865+ try :
866+ validation_report = _run_publish_validation (env_dir , remote = False )
867+ except (RemoteValidationError , ValueError ) as exc :
868+ console .print (f"[bold red]✗[/bold red] Publish validation failed: { exc } " )
869+ raise typer .Exit (1 ) from exc
870+ _render_publish_gate (validation_report )
871+
737872 console .print ("[bold cyan]Preparing to push to custom registry...[/bold cyan]" )
738873 if enable_interface :
739874 console .print ("[bold cyan]Web interface will be enabled[/bold cyan]" )
@@ -778,6 +913,11 @@ def push(
778913 return
779914
780915 ignore_patterns = _load_ignore_patterns (env_dir , exclude )
916+ if _excludes_versioned_report (ignore_patterns ):
917+ raise typer .BadParameter (
918+ "Push excludes must preserve the versioned author report at "
919+ f"{ _VERSIONED_VALIDATION_REPORT .as_posix ()} "
920+ )
781921
782922 # Ensure authentication for HuggingFace
783923 username = _ensure_hf_authenticated ()
@@ -814,6 +954,19 @@ def push(
814954 enable_interface = enable_interface ,
815955 )
816956
957+ console .print (
958+ "[bold cyan]Running strict publish validation in a dedicated Hugging Face Sandbox...[/bold cyan]"
959+ )
960+ try :
961+ validation_report = _run_publish_validation (staging_dir )
962+ except (RemoteValidationError , ValueError ) as exc :
963+ console .print (f"[bold red]✗[/bold red] Remote validation failed: { exc } " )
964+ raise typer .Exit (1 ) from exc
965+ _render_publish_gate (validation_report )
966+ _verify_snapshot_binding (staging_dir , validation_report )
967+ _write_versioned_validation_report (staging_dir , validation_report )
968+ _verify_snapshot_binding (staging_dir , validation_report )
969+
817970 if count > 1 :
818971 base_repo_id = repo_id
819972 for i in range (1 , count + 1 ):
0 commit comments