#!/usr/bin/env python3 """Publish the normalized PROCITY GLBs + thumbnails to the shared 3GOD depot. Mirrors the house push contract (meshgod/server.py:_push_3god) exactly: * auth cookie: 3g = hmac_sha256(GOD3_PW, "3god-ok").hexdigest() * GLB POST {depot}/api/upload?name= body=glb model/gltf-binary * thumb POST {depot}/api/thumb?name= body=png image/png Credentials come from the environment — this script never contains or logs the secret: export GOD3_PW=... # the shared depot password (ask John) export GOD3_DEPOT=https://digalot.fyi/3god # optional; this is the default python3 pipeline/publish.py # publish every GLB in _normalized/ python3 pipeline/publish.py --dry-run # list what WOULD upload, no network This is the one outward-facing step in the lane; run it deliberately. Uploads are idempotent (same name overwrites) and reversible via the depot's own delete. """ import os, sys, glob, hmac, hashlib, json, urllib.request, urllib.parse ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) NORM = os.path.join(ROOT, "pipeline", "_normalized") THUMBS = os.path.join(ROOT, "web", "assets", "thumbs") DEPOT = os.environ.get("GOD3_DEPOT", "https://digalot.fyi/3god").rstrip("/") PW = os.environ.get("GOD3_PW", "") DRY = "--dry-run" in sys.argv # assets intentionally excluded from publish (see AUDIT.md) SKIP = {"procity_fit_cube_shelf_01.glb"} def cookie(): tok = hmac.new(PW.encode(), b"3god-ok", hashlib.sha256).hexdigest() return f"3g={tok}" def to_jpeg(png_path): """PNG thumb → JPEG bytes (the depot's /api/thumb accepts jpeg only). Uses macOS sips (no deps); falls back to PIL if available elsewhere.""" import subprocess, tempfile if sys.platform == "darwin": with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as t: tmp = t.name try: subprocess.run(["sips", "-s", "format", "jpeg", "-s", "formatOptions", "85", png_path, "--out", tmp], check=True, capture_output=True) return open(tmp, "rb").read() finally: os.unlink(tmp) from PIL import Image # non-mac fallback import io buf = io.BytesIO() Image.open(png_path).convert("RGB").save(buf, "JPEG", quality=85) return buf.getvalue() def post(path, body, ctype): req = urllib.request.Request(DEPOT + path, data=body, method="POST", headers={"Content-Type": ctype, "Cookie": cookie()}) with urllib.request.urlopen(req, timeout=120) as r: return r.status, r.read().decode("utf-8", "replace")[:200] def main(): if "--verify" in sys.argv: # drift-check the record vs the live depot, no publish recpath = os.path.join(ROOT, "pipeline", "_published.json") record = sorted(set(json.load(open(recpath)))) if os.path.exists(recpath) else [] print(f"_published.json: {len(record)} entries") drift_check(record) return only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else "" # Passwordless tailnet path (round-3 decision #4): GOD3_DEPOT overridden to an allowlisted # tailnet endpoint (e.g. http://100.94.195.115:8788) trusts this box — no cookie needed. tailnet = DEPOT != "https://digalot.fyi/3god" glbs = sorted(g for g in glob.glob(NORM + "/*.glb") if os.path.basename(g) not in SKIP and (not only or only in os.path.basename(g))) if not glbs: print(f"no GLBs in {NORM} matching {only!r} — run normalize.py first"); sys.exit(1) print(f"depot: {DEPOT} assets: {len(glbs)} {'(DRY RUN)' if DRY else ''}" + (" [tailnet passwordless]" if tailnet else "")) if not DRY and not PW and not tailnet: print("ERROR: GOD3_PW not set and no tailnet GOD3_DEPOT. Either:\n" " export GOD3_DEPOT=http://100.94.195.115:8788 # passwordless from an allowlisted box\n" " or export GOD3_PW=... # public depot (ask John)") sys.exit(2) published = [] for g in glbs: name = os.path.basename(g) thumb = os.path.join(THUMBS, name.replace(".glb", ".png")) size = os.path.getsize(g) if DRY: print(f" would upload {name:38} {size//1024:>5}KB thumb={os.path.exists(thumb)}") continue try: st, msg = post("/api/upload?name=" + urllib.parse.quote(name), open(g, "rb").read(), "model/gltf-binary") # thumb is cosmetic — never fail the publish over it (house contract: # /api/thumb is keyed by the stored GLB name and wants JPEG) tstat = "" if os.path.exists(thumb): try: ts, _ = post("/api/thumb?name=" + urllib.parse.quote(name), to_jpeg(thumb), "image/jpeg") tstat = f" thumb:{ts}" except Exception as te: tstat = f" thumb:ERR({te})" ok = st in (200, 201) print(f" {'OK ' if ok else 'ERR'} {name:38} glb:{st}{tstat}") if ok: published.append(name) except Exception as e: print(f" ERR {name:38} {e}") if not DRY: # MERGE, never replace (decision #3): _published.json is the provenance record of everything # ever pushed — a --only run must not clobber it to just this run's files (the R5 bug). recpath = os.path.join(ROOT, "pipeline", "_published.json") existing = set() if os.path.exists(recpath): try: existing = set(json.load(open(recpath))) except Exception: pass merged = sorted(existing | set(published)) json.dump(merged, open(recpath, "w"), indent=2) print(f"\npublished {len(published)}/{len(glbs)} this run → " f"_published.json now {len(merged)} (was {len(existing)}).") drift_check(merged) def drift_check(record): """Compare _published.json (the published-name RECORD) against the depot's actual file list. A recorded name absent from the depot is real drift (deleted / never landed); a `procity_` file live on the depot but unrecorded is a provenance gap. R17 ledger #4: the old check filtered the depot list to `procity_*` and so false-flagged genuinely-published non-`procity_` assets (sit.glb, the reused vintage-cash-register) as "not live" — now every published name is confirmed by the name list itself, and only OTHER projects' non-`procity_` files on the shared depot are ignored (they're expected, not ours).""" try: live = fetch_depot_files() except Exception as e: print(f" (drift check skipped — depot list unreachable: {e})") return rec = set(record) missing_from_depot = rec - live # recorded but not live → real drift unrecorded = {f for f in live if f.startswith("procity_")} - rec # our namespace live but unrecorded if unrecorded: print(f" DRIFT: {len(unrecorded)} procity_ on depot not in _published.json: " f"{sorted(unrecorded)[:5]}") if missing_from_depot: print(f" DRIFT: {len(missing_from_depot)} in _published.json not live on depot: " f"{sorted(missing_from_depot)[:5]}") if not unrecorded and not missing_from_depot: print(f" ✓ _published.json matches the live depot (all {len(rec)} recorded assets live).") def fetch_depot_files(): """EVERY file name on the depot (not prefix-filtered), so the drift check confirms any published name — including non-`procity_` clips/reuses — is live by the name itself, not a prefix heuristic.""" import urllib.request as _u req = _u.Request(DEPOT + "/api/list", headers={"User-Agent": "procity-validator/1.0"}) with _u.urlopen(req, timeout=15) as r: d = json.loads(r.read().decode("utf-8")) assets = d.get("assets", d) if isinstance(d, dict) else d return {a["file"] for a in assets if a.get("file")} if __name__ == "__main__": main()