Gate the terminal captures on the pinned release - #10
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The captures were generated by hand and CI only greped for their text, so terminal changes could drift from the page unnoticed. Define the capture contract in tools/capture.py -- the release pinned in tools/termcade-release, a 96x30 pty, per-frame pixel modes, and explicit timing thresholds -- and add --check, which regenerates the deterministic frames in an empty state directory and fails on any byte difference. The pixels-* frames are that deterministic part: Asteroid opens every wave with the same ship dead centre, so the pinned release redraws them exactly. They are now written trimmed to the ship's bounding box, because the ship's absolute cell can wander by one between runs while its shape cannot. asteroid.html is clock-seeded and index-screen.html is the capturing machine's library, so --check asserts their structure only; the README documents how they are reviewed and refreshed. Starting the game is no longer a fixed key script: the index orders rows by play history, which is empty and unordered in a fresh state directory, so the capture reads the menu and walks the selection to ASTEROID's row. CI runs --check against the pinned, checksum-verified release on every pull request. Proven to fail: a hand-edited pixels-half.html exits 1 with 'stale capture(s): pixels-half.html'. Fixes aviorstudio/termcade-be#29
328f42c to
f051a66
Compare
|
Correction to the mechanism described in the PR body (first CI run failed, and the fix changed the design): The first pushed version failed CI in 15s with: Root cause, two layers deep:
So where the body above says the capture "reads the menu" to start the game, read: it reads the library screen. Verified after the change: 3 consecutive |
Review 1P1 — Release verification does not establish a trusted artifact
The workflow downloads both the executable and its checksum from the mutable release tag, then verifies one against the other. If the release assets are replaced (for example, after a release-maintainer or GitHub-token compromise), an attacker can replace both files; their binary can print P2 — The nondeterministic-frame assertions do not verify the documented structure
Residual risks: I reviewed only Verdict: NEEDS ATTENTION |
…ecks Review 1, findings 1-2. CI downloaded the archive and its checksum from the same mutable release tag, so replacing both assets would have CI executing an unreviewed binary. The SHA-256 of the Linux x86_64 archive is now committed in tools/termcade-release.sha256 and the download is verified against it; the release's own checksums.txt is out of the trust path. The nondeterministic frames were asserted on bare tokens, which a truncated or unrelated fragment could satisfy. They are now parsed back into cells and asserted on layout: the hero keeps its HUD rows, a rectangular edge border of consistent width, a canvas painted edge-to-edge inside it, and the score row; the index keeps its three-row wordmark, exactly one selection marker on a pick-styled row, the library action and the hint. Each failure names what is missing. drive/drive_game now reap the pty child and fd in a finally: an exception mid-capture previously leaked a running arcade (proven: child alive after a simulated failure; dead and closed after the fix).
Review 1 replyFinding 1, artifact trust: Fixed in Finding 2, structure: Fixed in Residual PTY cleanup: Also fixed. Both drivers now reap the child and close the FD in |
Review 2 replyNo findings require changes. The residual is accepted for this tooling-only layer: the assertions were each deliberately sabotaged and observed failing during implementation, while committing a second synthetic test harness for the capture harness would duplicate substantial PTY/parser setup. The production CI path runs the actual checks against the pinned binary, and head CI/Vercel are green. Disposition: Review 2 is approving. |
Review 2No findings. Verified dispositions: committed archive SHA-256 is checked before extraction; layout-aware checks validate both nondeterministic frames; Method: reviewed exact range Verdict: APPROVE |
Fixes aviorstudio/termcade-be#29
What
The frames were captured by hand and CI only greped
distfor their text, so terminal output could drift from the page while CI stayed green. This defines the capture contract and makes CI enforce the deterministic part of it.The contract (docstring of
tools/capture.py, summarised in the README):tools/termcade-release(currentlyv0.0.7).--checkverifiestermcade versionagainst the pin; CI downloads that exact release asset and verifies it against the release's ownchecksums.txtbefore running it.TERM=xterm-256color,COLORTERM=truecolor.TERMCADE_PIXELS=quadfor the index and hero frames; each style for its own crop.The gate:
python3 tools/capture.py --check <binary>regenerates the fourpixels-*.htmlin an isolated, empty state directory and fails on any byte difference. These four are deterministic in shape — Asteroid opens every wave with the same ship dead centre — so the pinned release redraws them exactly.Nondeterminism, handled honestly
Three real sources were found and measured while building this, and the gate is shaped around them:
games/asteroidreseeds onReset):asteroid.htmldiffers on every run no matter what is pinned. Not gated exactly; structure asserted (playfield border over the cleared canvas).class="pick b"). The README documents the review habit: refresh on release bumps, read the diff by eye — rocks and row order move every run, anything else moving is the signal.#000the page frames them on, so the page renders identically. Verified: 4 consecutive runs × 4 styles produced exactly one trimmed variant per style, and 10 consecutive--checkruns pass.Also fixed, because it made captures silently wrong: the game was started by a fixed key script (
l, Enter) against the index menu, but the menu's row order is play history — in a fresh state directory it is unordered (measured:j+Enter started BRICKOUGH in 2 of 3 runs), and a cold unpack could leave the run short of the wave entirely (the old "non-blank" filter then captured the library screen intopixels-quad.html). The capture now reads the menu, walks the selection to ASTEROID's row, and verifies the game screen actually came up.Proof the gate fails
Deliberately staled
src/frames/pixels-half.html(removed one glyph), ran the gate:then restored the file and re-ran:
captures are fresh: pixels-*.html match the pinned release v0.0.7 byte for byte; asteroid.html and index-screen.html have their expected structure, exit 0.Verification
python3 tools/capture.py --check <v0.0.7 release binary>: 10 consecutive passes (each is 4 wave captures in a fresh state dir).python3 tools/capture.py <binary>) regenerates all six frames; the four pixels files are byte-identical to the checked-in ones.bun install --frozen-lockfile && bun run build: 1 page built; existing dist assertions (class="logo b",class="on void",ASTEROID, no*.js) all still pass.CI
New
Capturesstep (composite action.github/actions/captures) between Build and Test: downloads the pinned release, verifies the checksum, runs--check. Runner needs nothing beyond ubuntu-latest's stockpython3andcurl.