Skip to content

Commit 8c2baf7

Browse files
committed
fix(deploy): prove upload snapshot before remote mutation
1 parent 712f986 commit 8c2baf7

3 files changed

Lines changed: 258 additions & 103 deletions

File tree

‎DEPLOY.md‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@ uploads only the filtered tracked set → uploads a generated HF-frontmatter
4848
## Deploy policy: only what git tracks, then prune
4949

5050
The deploy publishes **exactly the files git tracks** (`git ls-files`), never the
51-
working tree. Working-kitchen files — internal notes, audits, `.env`, scratch
52-
reports — are gitignored and a repo-hygiene CI gate fails if any slip into the
53-
index, so they cannot reach the Space. The upload also **prunes** the Space: files
54-
removed from the repo (or excluded from the publish set) are deleted on the host,
55-
not left behind from an earlier push.
51+
untracked working tree. Before `--apply`, every publish source plus `README.md`
52+
must match `HEAD`; the script materializes and re-scans the complete staging tree
53+
before it creates an HF client or changes a secret. Working-kitchen files —
54+
internal notes, audits, `.env`, scratch reports — are gitignored and a
55+
repo-hygiene CI gate fails if any slip into the index, so they cannot reach the
56+
Space. The upload also **prunes** the Space: files removed from the repo (or
57+
excluded from the publish set) are deleted on the host, not left behind from an
58+
earlier push.
5659

5760
`scripts/deploy_hf.py` does the following:
5861

@@ -65,9 +68,11 @@ not left behind from an earlier push.
6568
separately);
6669
4. scans the **bytes** of every publish file for tunnel clients and refuses to
6770
upload if any are found (see below);
68-
5. treats generated `README.md`, tracked `Dockerfile`, and `.gitattributes`
69-
(when present) as the expected remote set, then prunes remote strays;
70-
6. supports `--self-test` and `--dry-run` as strictly local modes.
71+
5. requires every source to exist and match `HEAD`, then stages and scans the
72+
complete upload before any remote-capable client is created;
73+
6. defines the expected remote tree as exactly that staged set plus the generated
74+
`README.md`, then prunes every other remote path;
75+
7. supports `--self-test` and `--dry-run` as strictly local modes.
7176

7277
Source-only deployment metadata (`DEPLOY.md`, `docs/PROJECT_CLOSURE.md`, and
7378
`scripts/deploy_hf.py`) is also excluded from the app host: these files document

‎scripts/deploy_hf.py‎

Lines changed: 145 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import argparse
2424
import os
2525
import re
26+
import shutil
2627
import subprocess
2728
import sys
2829
import 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:
348385
def 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

Comments
 (0)