From 074f52fddcc1cb94b8d2b804e116e14ed36181ff Mon Sep 17 00:00:00 2001 From: Timmy Date: Wed, 19 Aug 2026 03:00:54 +0000 Subject: [PATCH] build: add gated reproducible review releases --- README.md | 8 ++ scripts/build_release.py | 170 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100755 scripts/build_release.py diff --git a/README.md b/README.md index bcdd825..7dc69ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ A working mobile-first bowel diary with an optional **photo-first AI assist**: - [Product and safety boundary](PRODUCT.md) - [Self-hosted stool-vision decision](research/SELF-HOSTED-STOOL-VISION.md) +## Build a gated review release + +```bash +python3 scripts/build_release.py +``` + +The builder clones the committed `main` tree into an isolated directory, runs unit/security and both mobile acceptance suites, audits dependencies, checks syntax and secrets, excludes model weights and sensitive/generated media, and writes a checksummed archive plus `manifest.json` under `/root/timmy-releases/`. It refuses dirty or non-`main` source trees. + ## Run with the self-hosted open-weight path ```bash diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100755 index 0000000..385748c --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,170 @@ +#!/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 subprocess +import sys +import tarfile +import tempfile + +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,}"), +) + + +def run(args: list[str], cwd: Path, *, capture: bool = False) -> str: + print("+", " ".join(args), flush=True) + result = subprocess.run(args, cwd=cwd, check=True, text=True, + 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 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") + 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) + run(["npm", "run", "test:ui"], tree) + run(["npm", "run", "test:photo"], tree) + run(["npm", "audit", "--audit-level=high"], tree) + for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/vision-config.js", "src/vision-service.js"): + run(["node", "--check", file], tree) + run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) + run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.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" + "- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n" + "- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\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) + manifest = { + "schema_version": 1, + "project": "Timmy the Talking Turd", + "version": version, + "commit": commit, + "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, + "gates": { + "unit_security": "passed", + "mobile_green_path": "passed", + "photo_first_acceptance": "passed", + "dependency_audit_high": "passed", + "syntax": "passed", + "secret_scan": "passed", + "forbidden_artifacts": "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", encoding="utf-8") + print(json.dumps({"release_dir": str(release_dir), "archive": str(archive), "manifest": str(manifest_path), "sha256": digest}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())