#!/usr/bin/env python3 """Build a gated, reproducible Timmy review release from committed main.""" from __future__ import annotations import datetime as dt import hashlib import json import os from pathlib import Path import re import shutil import socket import subprocess import sys import tarfile import tempfile import time import urllib.request ROOT = Path(__file__).resolve().parents[1] OUT_ROOT = Path(os.environ.get("TIMMY_RELEASE_DIR", "/root/timmy-releases")) PUBLIC_REPO = "https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd" EXCLUDED_PREFIXES = ("video/", "research/source-pages/", "artifacts/") EXCLUDED_SUFFIXES = (".gguf", ".bin", ".safetensors", ".onnx", ".pyc") SECRET_PATTERNS = ( re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), re.compile(r"(?i)(?:api[_-]?key|access[_-]?token|authorization)\s*[:=]\s*['\"](?!\.\.\.|top-secret|test-|example|placeholder)[A-Za-z0-9_./+:-]{16,}"), ) # Captions that must be proven on camera (via the demo's own receipt) for the release to pass. DEMO_REQUIRED_CAPTIONS = ( "One clear photo action. Manual logging stays one tap away.", "Nothing uploads until explicit consent", "The suggestion stays provisional — nothing is applied or saved until you confirm it", "Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo", "Vision worker offline — the AI and consent steps disappear instead of failing after upload", "Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion", "Photo presence is a label, not a thumbnail — nothing sensitive is on screen until you ask", "Showing the photo is your explicit request, from this browser’s own storage", "Remove just the photo: the confirmed log, form, and note are all still here", "Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server", "Hermes Agent connected — free-text, contextual, and server-side", "Urgent language is intercepted deterministically before Hermes", "Bleeding reported as wiping still escalates, in the app’s own fixed words", "Tarry and black stool wording escalates too: one red flag, one unchanging message", "A tarry note already saved in the journal escalates the same way, before any provider call", "An out-of-range answer fails closed: nothing is applied and no provider detail reaches you", "A filled-in field that is out of range still fails closed, even when the other field came back blank", "Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident", "Every pattern line names the confirmed logs behind it, and following one shows exactly those records", "Two logs is not a pattern: the summary refuses to describe a trend it cannot support", "The answer states the count and cites the four confirmed logs it counted", "Following a citation opens exactly the confirmed log behind that answer", "Ask something the journal cannot answer and Timmy refuses instead of reassuring", "Training contribution is off by default: a saved log is never training data", "The consent version and the receipt are on screen before the contribute button works", "Withdrawing keeps a tombstone naming every derived artifact by contribution ID", "The corpus pipeline is a command-line tool, not a screen in the app — this runs the real ingest against the committed synthetic fixture", "Every ingested sample records consent, source digest, both hashes, derivation, review, and lifecycle — the caller file path is never stored", "Withdrawing deletes the derived image and the raw copy, keeps the original record, and appends a tombstone — the trainer sees zero active samples", "Revoking the same contribution twice is refused and appends nothing: the manifest only ever grows", "A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence", "An older backup is upgraded on import, and the notice says which version the file came from", "A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched", "A restored log still says it began as an AI suggestion you corrected — provenance is part of the record", ) def run(args: list[str], cwd: Path, *, capture: bool = False, env: dict[str, str] | None = None) -> str: print("+", " ".join(args), flush=True) result = subprocess.run(args, cwd=cwd, check=True, text=True, env=env, stdout=subprocess.PIPE if capture else None) return result.stdout.strip() if capture else "" def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def assert_port_free(host: str, port: int) -> None: """Fail closed when something is already listening where the acceptance server must bind.""" with socket.socket() as sock: sock.settimeout(1.0) if sock.connect_ex((host, port)) == 0: raise SystemExit( f"Port {port} is already served by another process; refusing to gate or record this " "release against a tree that is not the one under test." ) def assert_served_tree(tree: Path, host: str, port: int) -> None: """Prove the responding server is the clone under test, not a stale checkout on the same port.""" for relative in ("src/domain.js", "app.js"): served = urllib.request.urlopen(f"http://{host}:{port}/{relative}", timeout=5).read() expected = (tree / relative).read_bytes() if hashlib.sha256(served).hexdigest() != hashlib.sha256(expected).hexdigest(): raise SystemExit( f"The acceptance server is serving {relative} from a different tree; " "every gate and the demo would describe a release that is not this commit." ) def tracked_files(tree: Path) -> list[str]: names = run(["git", "ls-files"], tree, capture=True).splitlines() return sorted(name for name in names if name and not name.startswith(EXCLUDED_PREFIXES) and not name.endswith(EXCLUDED_SUFFIXES)) def scan_secrets(tree: Path, names: list[str]) -> None: forbidden_names = [name for name in names if Path(name).name == ".env" or name.endswith((".pem", ".key", ".p12"))] findings: list[str] = list(forbidden_names) for name in names: path = tree / name if not path.is_file() or path.stat().st_size > 2_000_000: continue try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue for pattern in SECRET_PATTERNS: if pattern.search(text): findings.append(name) break if findings: raise SystemExit("Secret scan failed: " + ", ".join(sorted(set(findings)))) def main() -> int: branch = run(["git", "branch", "--show-current"], ROOT, capture=True) if branch != "main": raise SystemExit(f"Release must build from main, not {branch!r}") status = run(["git", "status", "--porcelain"], ROOT, capture=True) if status: raise SystemExit("Release requires a clean committed tree") release_delta = os.environ.get("TIMMY_RELEASE_DELTA", "Verified rebuild of the current user-visible feature path — no new feature is claimed.").strip() if not release_delta: raise SystemExit("TIMMY_RELEASE_DELTA must not be empty") commit = run(["git", "rev-parse", "HEAD"], ROOT, capture=True) short = commit[:12] commit_epoch = int(run(["git", "show", "-s", "--format=%ct", commit], ROOT, capture=True)) source_date = dt.datetime.fromtimestamp(commit_epoch, dt.timezone.utc) release_date = dt.datetime.now(dt.timezone.utc).date().isoformat() version = f"{release_date}-{short}" release_dir = OUT_ROOT / version if release_dir.exists(): shutil.rmtree(release_dir) release_dir.mkdir(parents=True) with tempfile.TemporaryDirectory(prefix="timmy-release-") as tmp: tree = Path(tmp) / "source" run(["git", "clone", "--quiet", "--no-local", str(ROOT), str(tree)], ROOT) run(["git", "checkout", "--quiet", commit], tree) run(["npm", "ci", "--ignore-scripts"], tree) run(["npm", "test"], tree) # The committed corpus hashes are an integrity receipt: verify them before anything is built. run(["python3", "scripts/build_vision_corpus.py", "--verify"], tree) # Issue #25's corpus pipeline is a CLI, so its own suite is a release gate, not just CI. run(["python3", "tests/corpus-manifest.test.py", "-v"], tree) server_env = dict(os.environ) server_env.update({"HOST": "127.0.0.1", "PORT": "4173", "TIMMY_VISION_ENABLED": "false"}) # A leftover server from another checkout answers on this port and the readiness probe cannot # tell it apart from ours, so every gate and the recorded demo would silently describe a # different tree. Refuse to build over a live listener, and verify the served bytes afterwards. assert_port_free("127.0.0.1", 4173) server_log = (release_dir / "acceptance-server.log").open("w", encoding="utf-8") server = subprocess.Popen(["node", "server.mjs"], cwd=tree, env=server_env, stdout=server_log, stderr=subprocess.STDOUT, text=True) try: for _ in range(50): if server.poll() is not None: raise SystemExit("Acceptance server exited before readiness") try: urllib.request.urlopen("http://127.0.0.1:4173/", timeout=1).read(1) break except Exception: time.sleep(0.1) else: raise SystemExit("Acceptance server did not become ready") if server.poll() is not None: raise SystemExit("Acceptance server exited after reporting ready") assert_served_tree(tree, "127.0.0.1", 4173) run(["npm", "run", "test:ui"], tree) run(["npm", "run", "test:photo"], tree) run(["npm", "run", "test:mobile-capture"], tree) run(["npm", "run", "test:sleek"], tree) run(["npm", "run", "test:manual-fallback"], tree) run(["npm", "run", "test:provisional"], tree) run(["npm", "run", "test:followup"], tree) run(["npm", "run", "test:vision-refusal"], tree) run(["npm", "run", "test:worker-readiness"], tree) run(["npm", "run", "test:pattern-summary"], tree) run(["npm", "run", "test:grounded-chat"], tree) run(["npm", "run", "test:photo-lifecycle"], tree) run(["npm", "run", "test:training-contribution"], tree) run(["npm", "run", "test:portability"], tree) demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm" caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json" demo_env = dict(server_env) demo_env["TIMMY_RELEASE_VERSION"] = version demo_env["TIMMY_RELEASE_DEMO_DELTA"] = release_delta demo_env["TIMMY_DEMO_CAPTIONS_OUT"] = str(caption_receipt) run(["node", "scripts/record_release_demo.mjs", str(demo_raw)], tree, env=demo_env) if not caption_receipt.exists(): raise SystemExit("Release demo produced no on-camera caption receipt") recorded = json.loads(caption_receipt.read_text(encoding="utf-8")) onscreen = [str(line) for line in (recorded.get("captions") or [])] # A pushed string is not proof of pixels. Only captions the demo measured as a painted, # on-top, in-frame bar (style + elementFromPoint + band pixel change) count as on camera. verified = [str(item.get("text")) for item in (recorded.get("captionsVerified") or []) if item.get("visible") is True and item.get("bandChanged") is True] if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in verified): raise SystemExit("Release demo did not state the declared release delta on camera") for required in DEMO_REQUIRED_CAPTIONS: if not any(required in line for line in onscreen): raise SystemExit(f"Release demo is missing a required on-camera caption: {required}") if not any(required in line for line in verified): raise SystemExit(f"Release demo caption never painted on screen: {required}") finally: server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait(timeout=5) server_log.close() demo = release_dir / f"timmy-talking-turd-{version}-demo.mp4" run(["ffmpeg", "-y", "-v", "error", "-i", str(demo_raw), "-vf", "scale=720:1280:flags=lanczos", "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(demo)], tree) demo_raw.unlink() run(["ffmpeg", "-v", "error", "-i", str(demo), "-f", "null", "-"], tree) probe = json.loads(run(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(demo)], tree, capture=True)) video_streams = [stream for stream in probe.get("streams", []) if stream.get("codec_type") == "video"] if len(video_streams) != 1 or video_streams[0].get("codec_name") != "h264" or video_streams[0].get("pix_fmt") != "yuv420p" or video_streams[0].get("width") != 720 or video_streams[0].get("height") != 1280: raise SystemExit("Release demo media probe failed") contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg" # Span the whole recording: a fixed 1/2 fps sheet only covered the first 24s of a longer demo. demo_duration = float(run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(demo)], tree, capture=True)) if demo_duration <= 0: raise SystemExit("Release demo has no measurable duration") run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", f"fps={12 / demo_duration:.6f},scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree) if not contact_sheet.exists() or contact_sheet.stat().st_size == 0: raise SystemExit("Release demo contact sheet was not produced") run(["npm", "audit", "--audit-level=high"], tree) for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/training-contribution.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/training-contribution.acceptance.mjs"): run(["node", "--check", file], tree) run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/worker_ready.sh"], tree) run(["bash", "-n", "deploy/worker/entrypoint.sh"], tree) run(["bash", "-n", "deploy/worker/build_image.sh"], tree) run(["bash", "-n", "deploy/worker/run_worker_container.sh"], tree) run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py", "scripts/corpus_manifest.py"], tree) run(["git", "diff", "--check", commit], tree) names = tracked_files(tree) scan_secrets(tree, names) if any(name.startswith(EXCLUDED_PREFIXES) or name.endswith(EXCLUDED_SUFFIXES) for name in names): raise SystemExit("Release allowlist included a forbidden artifact") package_root = Path(tmp) / f"timmy-talking-turd-{version}" for name in names: src = tree / name dst = package_root / name dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) notes = ( f"# Timmy review release {version}\n\n" f"- Source commit: [{commit}]({PUBLIC_REPO}/commit/{commit})\n" f"- Build date (UTC): {release_date}\n" f"- Release delta: {release_delta}\n" "- Sleek three-destination shell with one dominant photo action and quiet manual fallback\n" "- Manual fallback: vision-offline logging stays one action away with an in-session draft and explicit start over\n" "- Smart free-text Timmy chat backed by authenticated server-side Hermes session continuity\n" "- Agent safety: exact-origin HttpOnly session, bounded text-only ledger context, no browser credentials or session IDs\n" "- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n" "- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n" "- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n" "- Vision corpus: a committed CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) proves every reply is schema-valid or fails closed; a schema-valid reply with unreadable fields abstains instead of erroring, and unparsable or out-of-range replies return plain copy with nothing applied — including a reply that fills in one field and leaves the other blank, where an out-of-range value still fails closed instead of being reported as an unreadable photo (issue #73)\n" "- Self-hosted worker container: a digest-pinned image that builds the pinned llama.cpp revision, mounts hash-verified weights read-only, runs unprivileged with no credential, and is published on loopback only (issue #15)\n" "- Post-load readiness: the app offers AI only once the pinned model is actually loaded, so a reachable-but-unloaded worker falls back to manual logging in one tap instead of failing after the upload (issue #15)\n" "- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n" "- Explainable pattern summaries (issue #34): the home insight card reports confirmed-log counts and form groupings per window, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend; no line explains a cause, rules on food, or offers a health verdict\n" "- Private photo lifecycle (issue #36): the journal marks a log that holds a saved photo without rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed field, deleting a log is a separate two-step decision that takes its photo, and delete-all removes the ledger and every stored photo together\n" "- Training contribution is a separate opt-in (issue #24): a journal save records no contribution, the consent version and the receipt are on screen before the control can be pressed, the receipt is queued on this device only (this build transmits nothing) and carries no note text, symptom answers, or photo bytes, and a withdrawal keeps a tombstone naming every derived artifact by contribution id\n" "- Auditable corpus pipeline (issue #25): every ingested training sample now records its consent version and timestamp, source kind and the raw-input digest (the caller's file path is never stored), both the raw and derived hashes, the derivation pipeline, the human review status, the split, and its lifecycle state in an append-only manifest; withdrawing a contribution deletes the derived image and any raw pre-derivation copy from the active dataset, keeps the original record as its own history, and appends a tombstone that names every removed artifact by contribution id and digest while dropping labels and the subject key; illegal lifecycle moves (re-ingesting a contribution, re-labelling a derived image, revoking twice, revoking an unknown contribution, reviving a revoked one, revoking without a reason) are refused before any byte is written\n" "- Scope note: the corpus pipeline is a command-line tool. There is no corpus screen in the app, and this release does not imply one — the recorded demo runs the real ingest and revoke commands against the committed synthetic fixture and shows their actual output\n" "- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n" "- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n" ) (package_root / "RELEASE-NOTES.md").write_text(notes, encoding="utf-8") archive = release_dir / f"timmy-talking-turd-{version}.tar.gz" with archive.open("wb") as raw: import gzip with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=commit_epoch) as gz: with tarfile.open(fileobj=gz, mode="w") as tar: for path in sorted(package_root.rglob("*")): arcname = path.relative_to(package_root.parent) info = tar.gettarinfo(str(path), arcname=str(arcname)) info.uid = info.gid = 0 info.uname = info.gname = "" info.mtime = commit_epoch if path.is_file(): with path.open("rb") as handle: tar.addfile(info, handle) else: tar.addfile(info) digest = sha256(archive) demo_digest = sha256(demo) manifest = { "schema_version": 1, "project": "Timmy the Talking Turd", "version": version, "commit": commit, "release_delta": release_delta, "source_date_utc": source_date.isoformat(), "built_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(), "artifact": archive.name, "bytes": archive.stat().st_size, "sha256": digest, "feature_demo": { "artifact": demo.name, "bytes": demo.stat().st_size, "sha256": demo_digest, "width": 720, "height": 1280, "codec": "h264", "pixel_format": "yuv420p", "recorded_from_working_app": True, "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion, plus the corpus audit card, where the real ingest and revoke commands run against the committed synthetic fixture and the card shows their actual output: the provenance record with consent, source digest, both hashes, derivation, review and lifecycle; the derived image deleted from disk; the original record kept as history beside a tombstone that drops labels and the subject key; zero active samples for a trainer; and a second revocation refused without appending anything", }, "gates": { "unit_security": "passed", "mobile_green_path": "passed", "photo_first_acceptance": "passed", "sleek_hermes_chat_acceptance": "passed", "manual_fallback_draft_recovery": "passed", "provisional_suggestion_state": "passed", "hard_stool_followup": "passed", "vision_corpus_refusal": "passed", "worker_container_receipts": "passed", "worker_post_load_readiness": "passed", "longitudinal_pattern_summary": "passed", "grounded_chat_citations": "passed", "training_contribution_consent": "passed", "corpus_manifest_provenance": "passed", "portable_backup_restore": "passed", "dependency_audit_high": "passed", "syntax": "passed", "secret_scan": "passed", "forbidden_artifacts": "passed", "feature_demo_full_decode": "passed", "feature_demo_media_probe": "passed", "release_delta_visible_on_camera": "passed", }, } manifest_path = release_dir / "manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") (release_dir / "SHA256SUMS").write_text(f"{digest} {archive.name}\n{demo_digest} {demo.name}\n", encoding="utf-8") print(json.dumps({"release_dir": str(release_dir), "archive": str(archive), "feature_demo": str(demo), "manifest": str(manifest_path), "sha256": digest, "feature_demo_sha256": demo_digest}, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())