Skip to content

Gate the terminal captures on the pinned release - #10

Merged
nicodes merged 2 commits into
mainfrom
issue-29-capture-freshness
Aug 21, 2026
Merged

Gate the terminal captures on the pinned release#10
nicodes merged 2 commits into
mainfrom
issue-29-capture-freshness

Conversation

@nicodes

@nicodes nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes aviorstudio/termcade-be#29

What

The frames were captured by hand and CI only greped dist for 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):

  • Binary: the GitHub release pinned in tools/termcade-release (currently v0.0.7). --check verifies termcade version against the pin; CI downloads that exact release asset and verifies it against the release's own checksums.txt before running it.
  • Terminal: a pty of exactly 96 columns × 30 rows, TERM=xterm-256color, COLORTERM=truecolor.
  • Pixel mode: TERMCADE_PIXELS=quad for the index and hero frames; each style for its own crop.
  • Timing: frames are taken only from full repaints (forced by a pty resize); repaint segments shorter than 400 bytes are dropped; capture ends 1.0s after the last scripted event; the pty is polled every 20ms; the resize nudge settles 150ms; a run that never reaches the wave is retried up to 3 times rather than captured wrong.

The gate: python3 tools/capture.py --check <binary> regenerates the four pixels-*.html in 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:

  1. Clock-seeded rocks (games/asteroid reseeds on Reset): asteroid.html differs on every run no matter what is pinned. Not gated exactly; structure asserted (playfield border over the cleared canvas).
  2. Machine-state index screen: recently-played row order and high scores are per-machine state. Not gated exactly; structure asserted (a selected row, 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.
  3. Ship position wander (found empirically): the wave-start ship's absolute cell can shift by one between identical runs (sub-cell spawn rounding) — a byte-for-byte gate on the raw crop would flake. The crops are now written trimmed to the ship's bounding box; the trimmed margin was the same #000 the 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 --check runs 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 into pixels-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:

$ python3 tools/capture.py --check /tmp/termcade
stale capture(s): pixels-half.html — regenerate with `python3 tools/capture.py <binary>` against the pinned release and review the diff
exit=1

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).
  • Full capture (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 Captures step (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 stock python3 and curl.

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
termcade-web Ready Ready Preview Aug 21, 2026 6:16am

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
@nicodes

nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

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:

quad: attempt 1/3: the index screen has no row for ASTEROID — is this the pinned release?
quad: no wave frame in 3 attempts — the arcade started slower than the capture contract allows
##[error]Process completed with exit code 1.

Root cause, two layers deep:

  1. My local "isolated" check runs were not isolated: termcade's scores/history live in os.UserConfigDir() (~/.config/termcade), and only HOME/XDG_DATA_HOME were being redirected. My real play history was leaking into every local run, so the index screen showed "recently played" rows that a truly fresh arcade (CI runner) does not have. A fresh arcade's index says "nothing played yet — pick a game from the library" and has no game rows at all. --check now also isolates XDG_CONFIG_HOME.
  2. With that understood, the right launch surface is the library, not the index: the index's recent rows are play history (unordered when empty), while the library is every installed game sorted by title (internal/plugin/registry.go) — the same rows on any machine. The capture now opens the library with l, waits until the game's row and the selection marker are painted (not "until the output goes quiet" — a cold unpack pauses between the logo and the list, longer than any honest idle threshold), walks the selection, waits until the marker has actually landed on the row, and only then presses Enter.

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 --check passes under a stripped CI-like environment (env -i) and 3 under a normal one, plus a full capture in a clean environment producing a byte-identical set of the four gated frames.

@nicodes

nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Review 1

P1 — Release verification does not establish a trusted artifact

.github/actions/captures/action.yml:24-27

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 termcade v0.0.7 and is executed by CI. Commit an expected SHA-256 (or verify signed provenance) in this repository and validate the downloaded archive against that immutable value.

P2 — The nondeterministic-frame assertions do not verify the documented structure

tools/capture.py:~545-559

asteroid.html passes solely if its text contains class="edge" and void; index-screen.html passes solely if it contains class="pick b". A truncated, stale, or unrelated fragment containing those tokens passes, so this does not establish the claimed playfield border/canvas relationship or selected library row. Use layout-aware assertions with sufficient required markers and positions.

Residual risks: I reviewed only 458aff9...f051a66 and inspected the full capture script, action, base CI, release checksum manifest, and tag reference. I did not execute the capture gate or perform deliberate-failure testing; supplied CI/Vercel success was noted. PTY cleanup is not protected by finally, so unexpected Python I/O/parse exceptions can leave a child or FD until process exit.

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).
@nicodes

nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Review 1 reply

Finding 1, artifact trust: Fixed in 4bfa4e4. The expected archive SHA-256 is committed in tools/termcade-release.sha256; CI validates the downloaded archive against it and no longer trusts a checksum fetched from the same mutable release. A tampered archive fails with termcade_Linux_x86_64.tar.gz: FAILED; the good archive and CI report OK.

Finding 2, structure: Fixed in 4bfa4e4. Token checks were replaced by a parser that reconstructs the cell grid and verifies the hero HUD/title/HIGH row, rectangular consistent-width border, fully painted canvas interior, and score/WAVE/hint row; the index verifies three logo rows, exactly one pick-styled selection marker, the library action, and controls hint. Deliberate truncation, unrelated fragments, removed marker, unpainted cell, and short row all fail with specific messages.

Residual PTY cleanup: Also fixed. Both drivers now reap the child and close the FD in finally. A simulated mid-capture exception left the child alive before the fix; the same path now leaves the child dead and FD closed. Capture checks and head CI/Vercel are green.

@nicodes

nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Review 2 reply

No 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.

@nicodes

nicodes commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Review 2

No findings.

Verified dispositions: committed archive SHA-256 is checked before extraction; layout-aware checks validate both nondeterministic frames; drive and drive_game reap/close via finally.

Method: reviewed exact range 458aff9...4bfa4e4, head capture/action/workflow/frame files, PR reply, and pinned termcade v0.0.7 behavior. Did not execute tests or mutate state. Residual risk: no committed automated unit tests for structural-check sabotage cases or exceptional PTY cleanup paths; CI/Vercel success was supplied.

Verdict: APPROVE

@nicodes
nicodes merged commit c61b51c into main Aug 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant