build: add gated reproducible review releases
This commit is contained in:
parent
0ecf1d1205
commit
074f52fddc
|
|
@ -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
|
||||
|
|
|
|||
170
scripts/build_release.py
Executable file
170
scripts/build_release.py
Executable file
|
|
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user