Ledger #4 (warm-up): publish.py --verify no longer false-flags non-procity_ published assets (sit.glb, vintage-cash-register). It compares the _published.json name record against the depot's full file list (fetch_depot_files) instead of a procity_ prefix filter; only OUR namespace flags as unrecorded. --verify now: all 39 recorded assets live. Ledger #6 (the scout): new pipeline/build_towns.py ports thriftgod build_city()'s Overpass route to per-town caches in A's procity-town-cache/1 contract. 5 real compact AU towns under web/assets/towns/ (katoomba/newtown/fremantle/bendigo/castlemaine, 6-21 shops, spans <2.4km): bounded Overpass query -> raw cached in-repo (re-runs never re-fetch) -> dedupe/suburb-fill/parody (trademarked chains only; indie shops keep real OSM names)/type-map. Deterministic (byte-identical re-run, no RNG). ODbL: SOURCES.md licence table + license/attribution on every cache. All 5 boot through the full structural+gig suites (selfcheck 16596/16596 green; synthetic golden 0x3fa36874 unmoved). Overpass fetch John-authorized (Standing #2). -> Lane A: caches live under web/assets/towns/; pin the per-town goldens selfcheck printed (katoomba 0x61760542 newtown 0x95978b45 fremantle 0xee9fd09a bendigo 0x5e3e76a6 castlemaine 0xf1a628a1) + feed through plan_osm. E->A handshake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
8.0 KiB
Python
167 lines
8.0 KiB
Python
#!/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=<file> body=glb model/gltf-binary
|
|
* thumb POST {depot}/api/thumb?name=<file> 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()
|