2323import argparse
2424import os
2525import re
26+ import shutil
2627import subprocess
2728import sys
2829import tempfile
@@ -146,28 +147,48 @@ def publish_paths(*, root: Path | None = None) -> list[str]:
146147 return filter_publish_paths (tracked_files (root = root ))
147148
148149
149- def expected_remote_paths (
150+ def expected_remote_paths (publish : Sequence [str ]) -> set [str ]:
151+ """Exact paths uploaded by this run plus its generated HF README."""
152+ return set (publish ) | {GENERATED_REMOTE_README }
153+
154+
155+ def dirty_tracked_files (* , root : Path | None = None ) -> list [str ]:
156+ """Tracked paths whose index or working-tree bytes differ from ``HEAD``."""
157+ cwd = repo_root () if root is None else root
158+ result = subprocess .run (
159+ [
160+ "git" ,
161+ "-c" ,
162+ "core.quotepath=false" ,
163+ "diff" ,
164+ "--name-only" ,
165+ "--diff-filter=ACDMRTUXB" ,
166+ "HEAD" ,
167+ "--" ,
168+ ],
169+ cwd = cwd ,
170+ capture_output = True ,
171+ text = True ,
172+ check = True ,
173+ encoding = "utf-8" ,
174+ )
175+ return [line for line in result .stdout .splitlines () if line ]
176+
177+
178+ def stage_publish_tree (
150179 publish : Sequence [str ],
151180 * ,
152- root : Path | None = None ,
153- has_gitattributes : bool | None = None ,
154- ) -> set [str ]:
155- """Remote paths that should remain after upload + prune.
156-
157- Always includes the generated frontmatter ``README.md`` and the tracked
158- ``Dockerfile``. Includes ``.gitattributes`` when present in the tree.
159- """
160- base = repo_root () if root is None else root
161- remote = set (publish )
162- remote .add (GENERATED_REMOTE_README )
163- remote .add (TRACKED_DOCKERFILE )
164- if has_gitattributes is None :
165- has_gitattributes = (base / TRACKED_GITATTRIBUTES ).is_file () or (
166- TRACKED_GITATTRIBUTES in remote
167- )
168- if has_gitattributes :
169- remote .add (TRACKED_GITATTRIBUTES )
170- return remote
181+ root : Path ,
182+ destination : Path ,
183+ ) -> None :
184+ """Copy every verified publish source before any remote client is created."""
185+ for rel in publish :
186+ source = root / rel
187+ if not source .is_file ():
188+ raise FileNotFoundError (f"publish source is missing or not a file: { rel } " )
189+ target = destination / rel
190+ target .parent .mkdir (parents = True , exist_ok = True )
191+ shutil .copyfile (source , target )
171192
172193
173194# ---------------------------------------------------------------------------
@@ -241,7 +262,11 @@ def build_hf_readme(*, root: Path | None = None) -> str:
241262# ---------------------------------------------------------------------------
242263
243264
244- def run_safety_gate (* , root : Path | None = None ) -> list [str ]:
265+ def run_safety_gate (
266+ * ,
267+ root : Path | None = None ,
268+ require_clean_publish : bool = False ,
269+ ) -> list [str ]:
245270 """Return human-readable failure strings; empty list means the gate passed."""
246271 base = repo_root () if root is None else root
247272 failures : list [str ] = []
@@ -254,6 +279,17 @@ def run_safety_gate(*, root: Path | None = None) -> list[str]:
254279 if missing_required :
255280 failures .append (f"required deploy files are not tracked: { missing_required } " )
256281
282+ required_sources = [* publish , "README.md" ]
283+ missing_sources = sorted (rel for rel in required_sources if not (base / rel ).is_file ())
284+ if missing_sources :
285+ failures .append (f"publish sources are missing or not files: { missing_sources } " )
286+
287+ if require_clean_publish :
288+ deploy_sources = set (required_sources )
289+ dirty_sources = sorted (deploy_sources & set (dirty_tracked_files (root = base )))
290+ if dirty_sources :
291+ failures .append (f"publish sources are dirty relative to HEAD: { dirty_sources } " )
292+
257293 for prefix in EXCLUDE_PREFIXES :
258294 leaked = [p for p in publish if p == prefix .rstrip ("/" ) or p .startswith (prefix )]
259295 if leaked :
@@ -275,12 +311,13 @@ def run_safety_gate(*, root: Path | None = None) -> list[str]:
275311 if leaks :
276312 failures .append (f"publish set ships a tunnel client (HF abuse rule): { leaks } " )
277313
278- generated_readme = build_hf_readme (root = base ).encode ("utf-8" )
279- if _TUNNEL_RE .search (generated_readme ) is not None :
280- failures .append ("generated HF README still carries a tunnel-client marker" )
314+ if (base / "README.md" ).is_file ():
315+ generated_readme = build_hf_readme (root = base ).encode ("utf-8" )
316+ if _TUNNEL_RE .search (generated_readme ) is not None :
317+ failures .append ("generated HF README still carries a tunnel-client marker" )
281318
282- if TRACKED_DOCKERFILE not in expected_remote_paths ( publish , root = base ) :
283- failures .append ("expected remote set must include Dockerfile " )
319+ if TRACKED_DOCKERFILE not in publish :
320+ failures .append ("Dockerfile must be in the publish/upload set " )
284321
285322 return failures
286323
@@ -294,7 +331,7 @@ def self_test(*, root: Path | None = None) -> int:
294331 print (f" - { line } " , file = sys .stderr )
295332 return 1
296333 publish = publish_paths (root = root )
297- remote = expected_remote_paths (publish , root = root )
334+ remote = expected_remote_paths (publish )
298335 print (
299336 f"[deploy-hf] self-test passed "
300337 f"({ len (publish )} publish file(s), { len (remote )} expected remote path(s))."
@@ -306,7 +343,7 @@ def dry_run(*, root: Path | None = None) -> int:
306343 """Print the upload set and local gate status without touching the network."""
307344 base = repo_root () if root is None else root
308345 publish = publish_paths (root = base )
309- remote = expected_remote_paths (publish , root = base )
346+ remote = expected_remote_paths (publish )
310347 print (f"[deploy-hf] dry-run: { len (publish )} file(s) would be bulk-uploaded" )
311348 for path in publish :
312349 print (f" + { path } " )
@@ -348,44 +385,67 @@ def _get_hf_api() -> Any:
348385def apply_deploy (* , root : Path | None = None ) -> int :
349386 """Run the local gate, then mutate the Space. Requires env secrets."""
350387 base = repo_root () if root is None else root
351- failures = run_safety_gate (root = base )
388+ failures = run_safety_gate (root = base , require_clean_publish = True )
352389 if failures :
353390 print ("[deploy-hf] --apply aborted: local safety gate failed:" , file = sys .stderr )
354391 for line in failures :
355392 print (f" - { line } " , file = sys .stderr )
356393 return 1
357394
358395 mistral_key = _require_env ("MISTRAL_API_KEY" )
359- # Token consumed inside _get_hf_api; never printed.
360- api = _get_hf_api ()
396+ _require_env ("HF_TOKEN" )
397+ publish = publish_paths (root = base )
398+ expected = expected_remote_paths (publish )
399+ generated_readme = build_hf_readme (root = base ).encode ("utf-8" )
361400
362- from huggingface_hub import CommitOperationDelete
401+ # Materialize and re-scan the complete upload before creating an HF client.
402+ # This prevents a sparse/deleted source from causing a partial remote mutation.
403+ with tempfile .TemporaryDirectory (prefix = "nl_sql_hf_upload_" ) as tmp :
404+ stage = Path (tmp )
405+ stage_publish_tree (publish , root = base , destination = stage )
406+ staged_leaks = tunnel_hits (publish , root = stage )
407+ if staged_leaks :
408+ print (
409+ f"[deploy-hf] --apply aborted: staged upload carries "
410+ f"tunnel markers: { staged_leaks } " ,
411+ file = sys .stderr ,
412+ )
413+ return 1
414+ if _TUNNEL_RE .search (generated_readme ) is not None :
415+ print (
416+ "[deploy-hf] --apply aborted: generated README carries a tunnel marker" ,
417+ file = sys .stderr ,
418+ )
419+ return 1
363420
364- publish = publish_paths (root = base )
365- expected = expected_remote_paths (publish , root = base )
421+ deploy_sources = set (publish ) | {"README.md" }
422+ dirty_after_stage = sorted (deploy_sources & set (dirty_tracked_files (root = base )))
423+ if dirty_after_stage :
424+ print (
425+ f"[deploy-hf] --apply aborted: publish sources changed during "
426+ f"staging: { dirty_after_stage } " ,
427+ file = sys .stderr ,
428+ )
429+ return 1
366430
367- if api .repo_exists (repo_id = REPO_ID , repo_type = "space" ):
368- print (f"[deploy-hf] Space { REPO_ID } already exists — skipping create_repo." )
369- else :
370- print (f"[deploy-hf] Creating Space { REPO_ID } (sdk={ SPACE_SDK } )…" )
371- api .create_repo (
372- repo_id = REPO_ID ,
373- repo_type = "space" ,
374- space_sdk = SPACE_SDK ,
375- exist_ok = True ,
376- )
431+ # Only now may a remote-capable client be created.
432+ api = _get_hf_api ()
433+ from huggingface_hub import CommitOperationDelete
377434
378- print ("[deploy-hf] Setting Space secret MISTRAL_API_KEY (value not printed)…" )
379- api .add_space_secret (repo_id = REPO_ID , key = "MISTRAL_API_KEY" , value = mistral_key )
435+ if api .repo_exists (repo_id = REPO_ID , repo_type = "space" ):
436+ print (f"[deploy-hf] Space { REPO_ID } already exists — skipping create_repo." )
437+ else :
438+ print (f"[deploy-hf] Creating Space { REPO_ID } (sdk={ SPACE_SDK } )…" )
439+ api .create_repo (
440+ repo_id = REPO_ID ,
441+ repo_type = "space" ,
442+ space_sdk = SPACE_SDK ,
443+ exist_ok = True ,
444+ )
445+
446+ print ("[deploy-hf] Setting Space secret MISTRAL_API_KEY (value not printed)…" )
447+ api .add_space_secret (repo_id = REPO_ID , key = "MISTRAL_API_KEY" , value = mistral_key )
380448
381- # Stage the filtered tracked set into a temp tree and upload as a folder.
382- with tempfile .TemporaryDirectory (prefix = "nl_sql_hf_upload_" ) as tmp :
383- stage = Path (tmp )
384- for rel in publish :
385- src = base / rel
386- dest = stage / rel
387- dest .parent .mkdir (parents = True , exist_ok = True )
388- dest .write_bytes (src .read_bytes ())
389449 print (f"[deploy-hf] Uploading { len (publish )} filtered tracked file(s)…" )
390450 api .upload_folder (
391451 folder_path = str (stage ),
@@ -394,34 +454,34 @@ def apply_deploy(*, root: Path | None = None) -> int:
394454 commit_message = "Deploy filtered tracked tree" ,
395455 )
396456
397- print ("[deploy-hf] Uploading generated README.md (HF frontmatter)…" )
398- api .upload_file (
399- path_or_fileobj = build_hf_readme (root = base ).encode ("utf-8" ),
400- path_in_repo = GENERATED_REMOTE_README ,
401- repo_id = REPO_ID ,
402- repo_type = "space" ,
403- commit_message = "HF Space README frontmatter" ,
404- )
405-
406- remote = set (api .list_repo_files (REPO_ID , repo_type = "space" ))
407- strays = sorted (remote - expected )
408- if strays :
409- print (f"[deploy-hf] Pruning { len (strays )} remote stray(s)…" )
410- ops = [CommitOperationDelete (path_in_repo = p ) for p in strays ]
411- api .create_commit (
457+ print ("[deploy-hf] Uploading generated README.md (HF frontmatter)…" )
458+ api .upload_file (
459+ path_or_fileobj = generated_readme ,
460+ path_in_repo = GENERATED_REMOTE_README ,
412461 repo_id = REPO_ID ,
413462 repo_type = "space" ,
414- operations = ops ,
415- commit_message = "Prune remote files not in filtered tracked set" ,
463+ commit_message = "HF Space README frontmatter" ,
416464 )
417- else :
418- print ("[deploy-hf] No remote strays to prune." )
419465
420- space_url = f"https://huggingface.co/spaces/{ REPO_ID } "
421- direct_url = f"https://{ REPO_ID .replace ('/' , '-' )} .hf.space"
422- print (f"[deploy-hf] Done. Dashboard: { space_url } " )
423- print (f"[deploy-hf] Direct: { direct_url } " )
424- return 0
466+ remote = set (api .list_repo_files (REPO_ID , repo_type = "space" ))
467+ strays = sorted (remote - expected )
468+ if strays :
469+ print (f"[deploy-hf] Pruning { len (strays )} remote stray(s)…" )
470+ ops = [CommitOperationDelete (path_in_repo = p ) for p in strays ]
471+ api .create_commit (
472+ repo_id = REPO_ID ,
473+ repo_type = "space" ,
474+ operations = ops ,
475+ commit_message = "Prune remote files not in filtered tracked set" ,
476+ )
477+ else :
478+ print ("[deploy-hf] No remote strays to prune." )
479+
480+ space_url = f"https://huggingface.co/spaces/{ REPO_ID } "
481+ direct_url = f"https://{ REPO_ID .replace ('/' , '-' )} .hf.space"
482+ print (f"[deploy-hf] Done. Dashboard: { space_url } " )
483+ print (f"[deploy-hf] Direct: { direct_url } " )
484+ return 0
425485
426486
427487# ---------------------------------------------------------------------------
@@ -467,6 +527,13 @@ def main(argv: list[str] | None = None) -> int:
467527 except RuntimeError as exc :
468528 print (f"[deploy-hf] { exc } " , file = sys .stderr )
469529 return 1
530+ except Exception as exc :
531+ print (
532+ f"[deploy-hf] --apply failed before completion "
533+ f"({ type (exc ).__name__ } ); no success reported." ,
534+ file = sys .stderr ,
535+ )
536+ return 1
470537
471538 # No flags: refuse mutation, print usage, nonzero — no network.
472539 parser .print_help ()
0 commit comments