#!/usr/bin/env python3 """Fail a dashboard release if demo servers can enter its production bundle.""" import argparse import hashlib import json import os import re from pathlib import Path MOCK_SERVER_NAMES = {"mock-backend.js", "content-payments.js", "films.js"} IMPORT = re.compile(r"(?:\bfrom\s*|\bimport\s*(?:\(\s*)?|\brequire\s*\(\s*)['\"]([^'\"]+)['\"]") def check(root: Path, dist: Path, builder: str, source_commit: str) -> dict: if os.environ.get("VITE_DEMO") != "0" or os.environ.get("VITE_DEMO_CONTENT") in {"1", "true"}: raise ValueError("production dashboard requires VITE_DEMO=0 and demo content disabled") if builder != "native-vite-production": raise ValueError("release builder must be native-vite-production, never a demo Dockerfile") if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise ValueError("exact source commit is required") source = root / "neode-ui" / "src" for path in source.rglob("*"): if not path.is_file() or path.suffix not in {".ts", ".tsx", ".js", ".vue"}: continue if "__tests__" in path.parts or path.name.endswith((".test.ts", ".spec.ts")): continue for specifier in IMPORT.findall(path.read_text(encoding="utf-8")): if "/mock/" in f"/{specifier}/" or any(name in specifier for name in MOCK_SERVER_NAMES): raise ValueError(f"production source imports a demo mock: {path.relative_to(root)}") if not (dist / "index.html").is_file(): raise ValueError("built dashboard index.html is missing") profile_path = dist / "ARCHY-BUILD-PROFILE.json" try: profile = json.loads(profile_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError("build-time profile is missing or invalid") from exc if profile != {"schema": 1, "builder": "vite-dashboard", "demo": False, "source_revision": source_commit}: raise ValueError("build-time profile does not bind production mode and exact source") entries = [] for path in sorted(dist.rglob("*")): if path.is_symlink(): raise ValueError(f"symlink in production static tree: {path.relative_to(dist)}") if not path.is_file(): continue relative = path.relative_to(dist).as_posix() if Path(relative).name in MOCK_SERVER_NAMES or relative.startswith(("mock/", "demo/")): raise ValueError(f"demo mock/server asset in production static tree: {relative}") digest = hashlib.sha256(path.read_bytes()).hexdigest() entries.append((relative, path.stat().st_size, digest)) tree = hashlib.sha256() for relative, size, digest in entries: tree.update(f"{relative}\0{size}\0{digest}\n".encode()) return {"builder": builder, "vite_demo": "0", "source_commit": source_commit, "files": len(entries), "tree_sha256": tree.hexdigest()} if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--repo-root", type=Path, required=True) parser.add_argument("--dist", type=Path, required=True) parser.add_argument("--builder", required=True) parser.add_argument("--source-commit", required=True) args = parser.parse_args() print(json.dumps(check(args.repo_root.resolve(), args.dist.resolve(), args.builder, args.source_commit), sort_keys=True))