timmy-talking-turd/scripts/build_release.py

171 lines
7.3 KiB
Python
Executable File

#!/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())