From d027a1e7914ceb94437abd1283266ec37666311e Mon Sep 17 00:00:00 2001 From: Timmy Release Bot Date: Sat, 10 Oct 2026 12:06:33 +0000 Subject: [PATCH] ci: state the release delta on camera and gate on an on-screen receipt A documentation-only release previously recorded a demo captioned 'FEATURE DEMO', which implied a shipped feature that did not exist. The demo now renders the builder-supplied release delta as its second caption, and the builder refuses to publish unless the demo's own machine-readable caption receipt proves that statement reached the screen and every required feature caption did too. The recording also shows the byte-bearing controls the photo data-flow threat model governs (consent-gated upload, portable export, delete all local data) using the app's real selectors, so a docs-only release demonstrates the nearest real user-visible effect instead of inventing UI. --- scripts/build_release.py | 32 +++++++++++++++++++++++++++ scripts/record_release_demo.mjs | 28 +++++++++++++++++++++-- tests/release-demo.test.js | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/scripts/build_release.py b/scripts/build_release.py index 738bf08..f6f77f4 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -26,6 +26,19 @@ SECRET_PATTERNS = ( 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", + "Delete all local data clears the ledger in this browser; there is no server copy of it to keep", + "Hermes Agent connected — free-text, contextual, and server-side", + "Urgent language is intercepted deterministically before Hermes", +) + def run(args: list[str], cwd: Path, *, capture: bool = False, env: dict[str, str] | None = None) -> str: print("+", " ".join(args), flush=True) @@ -74,6 +87,10 @@ def main() -> int: 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)) @@ -115,9 +132,21 @@ def main() -> int: run(["npm", "run", "test:provisional"], tree) run(["npm", "run", "test:followup"], 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 [])] + if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in onscreen): + 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}") finally: server.terminate() try: @@ -160,6 +189,7 @@ def main() -> int: 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" @@ -197,6 +227,7 @@ def main() -> int: "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, @@ -227,6 +258,7 @@ def main() -> int: "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" diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index cc4ccbd..ba4e010 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -1,9 +1,15 @@ import { chromium } from 'playwright'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; const output = path.resolve(process.argv[2] || 'release-demo.webm'); const version = process.env.TIMMY_RELEASE_VERSION || 'review build'; +// The release delta is supplied by the builder so the recording states, on camera, what actually +// changed in this release instead of implying a feature that did not ship. +const releaseDelta = process.env.TIMMY_RELEASE_DEMO_DELTA + || 'Verified rebuild of the current user-visible feature path — no new feature is claimed.'; +const captionsOut = process.env.TIMMY_DEMO_CAPTIONS_OUT || ''; +const captionsShown = []; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); await mkdir(path.dirname(output), { recursive: true }); @@ -71,6 +77,7 @@ await page.addStyleTag({ content: ` ` }); async function caption(text, duration = 1400) { + captionsShown.push(text); await page.evaluate(text => { document.querySelector('#demo-caption')?.remove(); const node = document.createElement('div'); @@ -120,7 +127,8 @@ async function indicate(selector) { await sleep(650); } -await caption(`TIMMY ${version} • FEATURE DEMO`, 1200); +await caption(`TIMMY ${version} • RELEASE DEMO`, 1200); +await caption(`This release: ${releaseDelta}`, 2600); await caption('Automated checks replay this synthetic path before review', 1200); await caption('One clear photo action. Manual logging stays one tap away.', 1500); await tap('[data-scan]', 450); @@ -186,6 +194,17 @@ await caption('One tap still reaches manual Bristol entry, and the draft keeps y await tap('#discard-draft', 500); await caption('Start over clears the kept draft — nothing lingers silently', 1500); await tap('#close-sheet', 450); +await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200); +await tap('[data-view="calendar"]', 550); +await page.getByText(/Your private journal/i).waitFor(); +await tap('[data-view="privacy"]', 550); +await page.getByText(/Your poop\. Your phone\./i).waitFor(); +await caption('Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion', 2800); +await indicate('#export'); +await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600); +await indicate('#delete-all'); +await caption('Delete all local data clears the ledger in this browser; there is no server copy of it to keep', 2600); +await tap('[data-view="home"]', 500); await tap('[data-view="timmy"]', 550); await page.getByText(/Hermes Agent connected/i).waitFor(); await caption('Hermes Agent connected — free-text, contextual, and server-side', 1400); @@ -214,6 +233,11 @@ await page.evaluate(() => { await sleep(2200); if (errors.length) throw new Error(`Browser errors: ${errors.join(' | ')}`); +// Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove +// the recorded delta statement reached the screen instead of trusting the script source. +if (captionsOut) { + await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown }, null, 2) + '\n', 'utf8'); +} const recording = page.video(); await page.close(); await recording.saveAs(output); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 8003d8f..529562b 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -97,3 +97,42 @@ test('release builder gates the hard-stool follow-up the same way CI does', asyn assert.match(builder, /"src\/followup\.js"/); assert.match(builder, /Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions/); }); + +test('release demo states the declared release delta on camera and proves it with a receipt', async () => { + const demo = await readFile(demoPath, 'utf8'); + + // The delta comes from the builder, so a documentation-only release cannot imply a new feature. + assert.match(demo, /TIMMY_RELEASE_DEMO_DELTA/); + assert.match(demo, /This release: \$\{releaseDelta\}/); + assert.match(demo, /no new feature is claimed/); + // A caption alone is not evidence; the demo must emit a machine-readable receipt of what rendered. + assert.match(demo, /TIMMY_DEMO_CAPTIONS_OUT/); + assert.match(demo, /captionsShown\.push\(text\)/); + assert.match(demo, /writeFile\(captionsOut/); +}); + +test('release demo shows the byte-bearing controls the threat model governs, without inventing UI', async () => { + const demo = await readFile(demoPath, 'utf8'); + + assert.match(demo, /\[data-view="privacy"\]/); + assert.match(demo, /Every byte-bearing control the model names/); + assert.match(demo, /indicate\('#export'\)/); + assert.match(demo, /indicate\('#delete-all'\)/); + assert.match(demo, /Delete all local data clears the ledger in this browser; there is no server copy of it to keep/); + // Real controls only: the selectors must exist in the app shell, not be demo-only scaffolding. + const app = await readFile(new URL('../app.js', import.meta.url), 'utf8'); + assert.match(app, /id="export"/); + assert.match(app, /id="delete-all"/); +}); + +test('release builder refuses to publish without the on-camera delta receipt', async () => { + const builder = await readFile(builderPath, 'utf8'); + + assert.match(builder, /TIMMY_RELEASE_DELTA/); + assert.match(builder, /TIMMY_DEMO_CAPTIONS_OUT/); + assert.match(builder, /Release demo produced no on-camera caption receipt/); + assert.match(builder, /Release demo did not state the declared release delta on camera/); + assert.match(builder, /Release demo is missing a required on-camera caption/); + assert.match(builder, /"release_delta_visible_on_camera": "passed"/); + assert.match(builder, /"release_delta": release_delta/); +});