Files
timmy-talking-turd/scripts/build_release.py
T
timmy 4cc5f4f7b1
Quality gates / quality (pull_request) Successful in 2m53s
feat: user-reported hard-stool follow-up with deterministic red-flag handoff (#67)
Field finding #67: a hard/lumpy (Bristol 1-2) entry now asks a short follow-up
before saving — difficult to pass, soreness, visible red blood, truly
black/sticky/tarry versus dark brown, and dehydration. Every answer is
user-reported and starts unanswered; nothing is inferred from the photo, and
answering "dark brown" never silently escalates as black stool.

A confirmed red-flag answer writes the same symptom flag manual logging already
uses, so the existing deterministic escalation is the only escalation path.
Mild reports get concise non-diagnostic self-care that states the photo cannot
rule out bleeding, and it is withdrawn whenever a red flag is confirmed.

- src/followup.js: pure follow-up contract (9 unit tests, tests/followup.test.js)
- app.js + styles.css: follow-up block on the safety step for Type 1-2 entries only
- tests/followup.acceptance.mjs: browser acceptance incl. that the AI suggestion
  surface still contains no symptom wording
- CI + release builder gates: npm run test:followup, syntax checks, manifest gate
- scripts/record_release_demo.mjs: records the real follow-up, mild guidance, and
  the black/tarry alert handoff with synthetic data
2026-10-09 08:10:43 +00:00

241 lines
12 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
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,}"),
)
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 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)
server_env = dict(os.environ)
server_env.update({"HOST": "127.0.0.1", "PORT": "4173", "TIMMY_VISION_ENABLED": "false"})
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")
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)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
demo_env = dict(server_env)
demo_env["TIMMY_RELEASE_VERSION"] = version
run(["node", "scripts/record_release_demo.mjs", str(demo_raw)], tree, env=demo_env)
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"
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], 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/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", "tests/staging.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(["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"
"- 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"
"- 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)
demo_digest = sha256(demo)
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,
"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, offline vision status, and deterministic Hermes chat response",
},
"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",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
"forbidden_artifacts": "passed",
"feature_demo_full_decode": "passed",
"feature_demo_media_probe": "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())