Skip to content

ci: run the visual capture harness on macOS and require it to be deterministic - #199

Merged
tylerkron merged 9 commits into
mainfrom
ci/determinism-gate-191
Sep 2, 2026
Merged

tylerkron merged 9 commits into
mainfrom
ci/determinism-gate-191

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review. Mergeable now, but land it after #202: see
Status.

closes #191

What this catches that nothing caught before

The visual capture harness renders the real app's UI against a wall-clock animation
clock. When it races itself it produces differences indistinguishable from a real
regression, and this project has already paid for that twice:

  • A 65.6% "visual regression" between two runs of the same binary at the same
    commit
    , which was an unfinished fade-in.
  • Two live settle-loop races found while doing fix(parity): the visual gate now captures on macOS — two settle-loop races fixed, no CI gate yet #179 by running exactly this mode on
    macOS: mobile-portrait-3-storage saved mid-fade with 81% of its pixels wrong
    (~1 run in 8), and the three right-hand drawers' shared SplitView reopened
    mid-close, flipping a 6-pixel column on the pane edge 50/50 per run.

Both were fixed, and nothing stopped the next one landing. #179 added the macOS capture
leg and deliberately added no CI job — but the reason it gave was about
--check-baseline specifically: that manifest has been reproduced on exactly one Mac,
so gating it would fail on its first run and read as a UI regression. That reasoning
does not reach --determinism, which compares the runner against itself.

On its first CI run this gate found a third one, which is the whole argument for
having it. It is now fixed in #202. Details below.

What the step does

The existing Desktop head on macOS job gains one step plus a failure-only upload. It
runs tools/parity-audit/run.sh --determinism into a RUNNER_TEMP out-dir: five
headless captures of the real app, every PNG required to be byte-identical to the first
run's. It is the first thing in this workflow that executes this repo's UI code rather
than compiling it.

The two traps #191 names:

  1. The screen count is asserted, in CI, as 18. A determinism run compares whatever
    both runs contain, so a consistently absent screen reads as a clean 17/17 byte-identical rather than as an incomplete capture. fix(parity): the visual gate now captures on macOS — two settle-loop races fixed, no CI gate yet #179 guards that inside the
    harness (ExpectedScreens); the CI check is a deliberate second, independent
    copy
    of the contract, because the harness's list is a variable a change can edit
    downwards — and if the only record of the expected count sits beside the code that
    produces it, a gate that shrinks stays green. Adding a screen now means editing two
    places on purpose.
  2. <out>/determinism/ is uploaded on failure (artifact parity-determinism-macos),
    so the differing PNGs are in hand. fix(parity): the visual gate now captures on macOS — two settle-loop races fixed, no CI gate yet #179 already had to fix a cleanup that destroyed
    the evidence its own failure message asked for. The upload is conditioned on
    failure() && steps.determinism.outcome == 'failure', not a bare failure(): a
    bare one would also fire after an earlier failure (restore, the arm64 check),
    uploading nothing and adding a second, empty red step to a job that already said
    what was wrong.

Also here, because the above made them stale or attributable:

Out of scope per #191, deliberately: gating --check-baseline (blocked on #188) and
any Linux capture gate (never watched to succeed there).

The failure artifact was dropping the one file a pre-PNG failure leaves behind

Trap 2 above was only half true when it was written, and the missing half is the case
that needs it most.

run.sh's run_capture sends each pass's full stdout and stderr to
<out>/.capture-determinism-N.log, and its own failure line points straight at it
(!! determinism-3 failed (see …)). Two independent things kept those logs out of the
artifact:

  1. They are written at the output ROOT — one level above <out>/determinism,
    which is the entire path: of the upload. Nothing under path: ever matched them,
    so include-hidden-files: true on its own would not have fixed this.
  2. They are dotfiles, which actions/upload-artifact drops by default.

The case that pays for this is the one with the least to go on. A pass that dies
before writing a single PNG — a crash during boot, a build error out of dotnet run — leaves <out>/determinism/rN empty, so the upload matched zero files,
if-no-files-found: warn shrugged, and no artifact was produced at all. #191 asked
for this gate on the grounds that a determinism failure is only actionable with the
evidence in hand; a failure with no PNGs is only actionable with the log, and the log
was the one thing being thrown away. That is #179's cleanup-destroys-the-evidence trap
wearing different clothes.

A new failure-only step copies the logs to <out>/determinism/capture-logs/, dropping
only the leading dot, so capture-determinism-3.log in the artifact is the
.capture-determinism-3.log the failing step named on screen.

Copied rather than added to path:, deliberately. Adding the glob would force
include-hidden-files: true, and that flag is not selective: AvaloniaCapture points
DAQIFI_DATA_DIR at <out>/determinism/rN/.appdata, inside the tree being uploaded,
so turning hidden files on would also sweep in every run's throwaway SQLite DB and app
logs. Copying keeps that default exclusion doing its job, and puts the evidence where a
plain ls on the unzipped artifact will show it rather than under a name ls hides —
a trap tools/parity-audit/README.md already warns readers about.

The step is the only place that knows run_capture's log naming, so it refuses to fail
quietly: no match emits a ::warning:: and writes a NO-CAPTURE-LOGS.txt into the
artifact naming the glob it looked for and the step to update.

Nothing about the gate itself moves. Five runs, byte identity, and the independent
18-screen assertion are all unchanged.

What it found, on its first run

Run 33655089083
failed, uploaded its captures, and the artifact says exactly what happened: run 4 of 5
wrote desktop-8-livegraph-settings-flyout with six pixels one unit off, every one
of them in column x=1059 — the SplitView pane's own left edge (1440 − 380 − 1).
0.0005% of the frame, and the same signature as the flip #179 fixed for desktop-9.

A 15-run job (33657356313)
put a rate on it: 3 failures in 15, on desktop-8 and desktop-9, always those
same six pixels. Across four macos-latest jobs the tally was 30 capture runs and 7
carrying the flip, against 40 clean runs on one M-series Mac.

Worth recording because it is the opposite of what one would guess: this is not a
cross-machine problem. Of the 90 screen-by-screen comparisons between that Mac's set
and the runner's five, 89 are byte-identical; the one mismatch is this flip.

Two fixes tried on this branch were both wrong, and their commits and reverts are in
the history with the measurements: requiring two consecutive identical settle
comparisons (passed a 5-run job, failed the 15-run one at the same rate), and a 500 ms
settle floor (worse — it reproduced the flip on the 40-for-40 Mac, 2 in 12). More
time selected a different wrong answer, which is what said the settle loop was not
where the bug lived. That was filed as #201.

Status

#201 is fixed in #202, and that is the only thing that was holding this PR.

#202's root cause turned out not to be the settle loop at all: Encode called
CaptureRenderedFrame(), which reads the persistent headless framebuffer that the
compositor updates in place a dirty region at a time — so the saved bytes were a
function of the redraw history rather than of the visual tree, and no settle rule could
ever have seen that. It now renders into a fresh RenderTargetBitmap.

Confirmed on real runs rather than on the claim. #202 carried a temporary commit that
enabled this gate on its own branch, and the macOS job reported
determinism OK: 18/18 screens byte-identical across 15 runs:

And directly for this branch, because #202's evidence used DETERMINISM_RUNS=15 rather
than the setting this gate actually ships: this branch merged with #202 goes green
on macos-latest at the real 5-run default — run
33669480144.

Merge order: this PR lands after #202. Without #202 the gate is red at roughly 1
capture run in 5, so a 5-run job fails about two times in three, and the red check on
this head is exactly that and nothing else: on run
33670298263 the
only failing step is the determinism one, Collect the capture logs and the upload both
succeeded after it, and the artifact shows the familiar signature —
desktop-8-livegraph-settings-flyout at 52,114 bytes on r1 and 52,137 on r4. Merging in
the other order puts a known-red required check on main.

Weakening the check — a pixel tolerance, a retry, a smaller run count — would be the
"gate that quietly shrinks its own scope" this ticket exists to prevent, so nothing of
the kind is here.

Verification

  • The gate, watched on macos-latest. Green:
    33656922699 —
    determinism OK: 18/18 screens byte-identical across 5 runs, then the step's own
    18 screens, byte-identical across every run. Red, with the artifact:
    33655089083 and
    33657356313.
  • Screen count: 18, asserted by the step and reported by the harness on every run.
    The count guard was tested in both directions locally, against a real capture set and
    against a truncated copy: 18 passes, 1 fails with the diagnostic and an ls.
  • The artifact fix, proven in both directions on real macos-latest runs — the
    same bar the original upload condition was held to. All three artifacts were
    downloaded and their contents listed, not inferred from step status.
    • Failing with no PNG ever written — the case that used to produce nothing at
      all. Scratch branch with a forced pre-capture crash, run
      33669444638:
      determinism failure → collect success → upload success. The artifact is
      4,903 bytes and its complete contents are one file,
      capture-logs/capture-determinism-1.log, carrying
      Unhandled exception. System.InvalidOperationException at line 228. Before this
      change that upload matched zero files and produced no artifact.

    • Failing with PNGs — this PR's own CI, red on the real parity: the capture harness flips the SplitView pane edge on GitHub macOS runners, ~1 run in 5 #201 flip: runs
      33669352002
      and 33670298263.
      Artifact 2.79 MB: r1..r5/ with all 90 PNGs, plus capture-logs/ with all five
      logs. find -name '.*' over the unzipped artifact returns nothing, so the
      throwaway .appdata — which exists on this run, the app booted — is still
      excluded. The copy did not cost the default exclusion, which is the whole reason
      for copying rather than include-hidden-files: true.

      The logs turned out to carry evidence the PNGs do not surface as readily. Straight
      off capture-logs/ on the second of those runs, without decoding an image:

      desktop-8-livegraph-settings-flyout: 52,114 bytes, settled in 2 round(s)   (r1)
      desktop-8-livegraph-settings-flyout: 52,137 bytes, settled in 1 round(s)   (r4)
      

      Two stable encodings with their settle-round counts beside them — the parity: the capture harness flips the SplitView pane edge on GitHub macOS runners, ~1 run in 5 #201
      signature, readable at a glance. That was not the argument for collecting them,
      but it is a good one.

    • Passing — runs
      33669480144
      and 33670132486:
      determinism success, and both the collect step and the upload skipped on
      each. Their artifacts are assets-android and assets-desktop only — no
      parity-determinism-macos.

  • Cost: this change adds nothing to a green job, because the collect step is
    failure()-gated and shows as skipped. Re-measured on those two green runs: the
    determinism step took 55 s and 60 s, and the job 1 m 36 s and 1 m
    52 s
    , against the 95 s / 2 m 35 s recorded here earlier and 63 s for the job on
    main. That saving belongs to fix(parity): the capture harness photographed a surface with a history, not the UI (#201) #202, not to this PR — rendering the tree is
    cheaper than reading the framebuffer back on every settle round.
  • Local gate on this branch, all green: Desktop build, iOS restore, iOS -c Release -r iossimulator-arm64 --no-restore build, and git status -- '**/packages.lock.json'
    clean afterwards. The Android head cannot build on this Mac (workload absent); CI
    covers it. No warning-count claim: this change compiles nothing new.

Diff against main: 218 added / 23 removed in .github/workflows/build.yml,
13 added in tools/parity-audit/README.md. No other file differs; the two
experiment commits are reverted in full.

🤖 Generated with Claude Code

…rministic

#179 gave the visual gate a macOS capture leg and deliberately added no CI job to
run it. The reason it gave was about --check-baseline specifically: the committed
macos-arm64 manifest has been reproduced on exactly one Mac, so gating it would
fail on its first run and read as a UI regression. That reasoning does not reach
--determinism, which compares the runner against ITSELF and asserts nothing about
any other machine's bytes.

So the `Desktop head on macOS` job now runs
`tools/parity-audit/run.sh --determinism` into a RUNNER_TEMP out-dir: five headless
captures of the real app, every PNG required to be byte-identical to the first
run's. That is the first thing in this workflow that executes this repo's UI code
rather than compiling it, and it is what stops the next self-race in the capture
harness reaching main — the two #179 found (a pane saved mid-fade, ~1 run in 8; the
shared SplitView reopened mid-close, a 6-pixel edge flip, 50/50) were found by
nothing except this check.

Two things the step has to get right, and does:

  * It asserts the capture produced 18 screens. A determinism run compares whatever
    both runs contain, so a consistently absent screen reads as a clean "17/17
    byte-identical" rather than as an incomplete capture. #179 guards that inside
    the harness (ExpectedScreens); this is a deliberate SECOND copy of the contract,
    because the harness's list is a variable a change can edit downwards.
  * It uploads <out>/determinism/ when — and only when — this step is the step that
    failed. A determinism failure is unactionable without the differing PNGs, and
    #179 already had to fix a cleanup that destroyed the evidence its own failure
    message asked for.

The harness is also restored explicitly, so an NU1004 lands on a restore step and
the existing lock-file guard covers AvaloniaCapture's lock file on a macOS
derivation host — which nothing did before (#106 was exactly a host-dependent key).

Out of scope, and the job header now says so in place of the stale claim that the
harness is not run here at all: gating --check-baseline (blocked on #188) and a
Linux capture gate (never watched to succeed there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Gate macOS visual captures on deterministic 18-screen output

🧪 Tests ⚙️ Configuration changes 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Run five macOS visual captures and require byte-identical output across all 18 screens.
• Restore capture dependencies explicitly and preserve failure PNGs as CI artifacts.
• Document determinism gating while deferring host-specific baseline validation.
Diagram

sequenceDiagram
    actor PR as Pull Request
    participant CI as GitHub Actions
    participant Job as macOS Job
    participant Script as parity run.sh
    participant App as Avalonia Capture
    participant Art as Artifact Store
    PR->>CI: Trigger workflow
    CI->>Job: Start required check
    Job->>Script: Run determinism
    loop Five captures
        Script->>App: Render 18 screens
        App-->>Script: Return PNG set
    end
    Script->>Script: Compare bytes
    Script-->>Job: Return result
    Job->>Job: Assert 18 screens
    alt Determinism failed
        Job->>Art: Upload captures
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Create a dedicated visual-gate job
  • ➕ Isolates visual failures from Desktop build and native-asset checks
  • ➕ Makes runtime duration and failures independently visible
  • ➖ Duplicates macOS checkout, SDK setup, restore, and billed runner time
  • ➖ Would require adding another required check to repository settings
2. Trust only the harness screen contract
  • ➕ Avoids duplicating the expected screen count in workflow configuration
  • ➕ Keeps capture-set ownership entirely within AvaloniaCapture
  • ➖ A change that shrinks ExpectedScreens could silently reduce CI coverage
  • ➖ Removes the independent guard against consistently incomplete captures

Recommendation: Keep the proposed approach. Reusing the existing required macOS job avoids another expensive runner while adding runtime coverage after its existing restore and build checks. The separate 18-screen assertion is intentional contract duplication that prevents the harness and its own expected-screen list from shrinking together unnoticed; a dedicated job is only preferable if execution time or failure isolation later becomes problematic.

Files changed (2) +150 / -23

Documentation (1) +6 / -0
README.mdDocument the macOS determinism CI gate +6/-0

Document the macOS determinism CI gate

• Explains that determinism now runs on every pull request, independently checks the 18-screen capture count, and retains failed captures as an artifact. It also clarifies why machine-specific baseline validation remains outside CI.

tools/parity-audit/README.md

Other (1) +144 / -23
build.ymlAdd deterministic visual capture gating to the required macOS job +144/-23

Add deterministic visual capture gating to the required macOS job

• Explicitly restores the Avalonia capture harness, runs five headless capture passes, and requires byte-identical PNG output for all 18 screens. Failed determinism steps upload their capture directory while earlier job failures do not trigger a misleading artifact upload; workflow comments also clarify runtime coverage and baseline exclusions.

.github/workflows/build.yml

@qodo-code-review

qodo-code-review Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit c6dfa35 ⚖️ Balanced

Results up to commit 9124a2c ⚖️ Balanced


No changes from previous review

Results up to commit 9496201 ⚖️ Balanced


No changes from previous review

Grey Divider

Qodo Logo

tylerkron and others added 4 commits September 2, 2026 10:45
…is not settled

The CI determinism gate added in the previous commit failed on its first run on a
GitHub macos-latest runner — a host this harness had never been executed on — and
uploaded the evidence, which is exactly what it is for (run 33655089083, job
100331537976).

What it caught: run 4 of 5 wrote desktop-8-livegraph-settings-flyout with 6 pixels
one unit off, every one of them in the SplitView pane's left edge column (x=1059).
That is 0.0005% of the image and the same signature as the flyout-edge flip #179
fixed for desktop-7 and desktop-9. The other four runs, 20 consecutive runs on an
M-series Mac, and the committed baselines/macos-arm64.sha256 all agree byte for byte
— so 89 of the 90 screen-vs-screen comparisons between this Mac and that runner are
identical, and the one that is not is this.

Cause: the headless render timer is MANUAL, so an animation advances only while
Pump() ticks it. The settle loop is therefore driving the UI, not observing it, and
stopping the loop freezes whatever frame the animation had reached. Near the end of a
transition the remaining motion is sub-pixel, two samples 50 ms apart render
identically, and the loop stops with the animation parked a hair short of its target
— saving a frame that is stable, plausible, and wrong. It is the quietest member of
this family: the 65.6% and 81% cases before it announced themselves.

Fix: require two CONSECUTIVE identical comparisons (SettleStableSamples), resetting
the count on any change. That buys 50 ms more of ticked animation clock past the
point where the picture stopped moving, which is longer than any tail in this app.

It cannot change a frame that had genuinely finished, and does not: with this in
place 20 local runs produce all 18 screens byte-identical to the 20-run set captured
before it, and `run.sh --check-baseline` still passes against the committed manifest.
Cost is one extra 50 ms round per screen, ~0.9 s per capture pass (measured: 7.1 s to
8.2 s a run on this Mac).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he runner

Reverted in the next commit. Five green runs against a defect that showed up once in
five is ~33% likely by luck; fifteen brings that to ~3.5%. This commit exists so the
evidence for the previous one is on the record with a run id attached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll pair is not settled"

That fix was wrong, and the gate it was written for is what proved it. Requiring two
consecutive identical comparisons passed a 5-run job on the runner, then failed a
15-run one three times (run 33657356313) at the same ~1-in-5 rate as before. The
flyout-edge flip is not an animation stopping one sample early.

A settle FLOOR was tried next and was worse: with 10 rounds (500 ms) forced before any
frame is accepted, the same 6-pixel flip reproduced on a Mac that had been clean for
40 consecutive runs (2 failures in 12), and desktop-7-notifications-flyout settled to a
THIRD encoding that no previous run had produced. So the picture is still changing
after half a second, and waiting longer selects a different wrong answer rather than
the right one. Both experiments are dead ends and neither belongs in the tree.

What is left is the measurement, which is in the follow-up issue: two stable encodings
of column x=1059 — the SplitView pane's own left edge — differing by one unit on six
pixels, on whichever flyout the run happens to hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

…s behind

The determinism gate uploads `<out>/determinism/` when it fails, so a reviewer has
the differing PNGs. But `run.sh`'s `run_capture` writes each pass's full stdout and
stderr to `<out>/.capture-determinism-N.log` — at the output ROOT, one level above
the directory being uploaded, and as a dotfile. Two independent reasons nothing
under the upload's `path:` ever matched them, which is why `include-hidden-files`
on its own would not have fixed it either.

The case that pays for this is the one with the least to go on. A pass that dies
BEFORE writing a single PNG — a crash during boot, a build error out of `dotnet run`
— leaves `<out>/determinism/rN` empty, so the upload found no files at all,
`if-no-files-found: warn` shrugged, and no artifact was produced. #191 asked for
this gate because a determinism failure is only actionable with the evidence in
hand; a failure with no PNGs is only actionable with the log, and the log was the
one thing being thrown away. That is #179's cleanup-destroys-the-evidence trap in
new clothes.

A new failure-only step copies the logs into `<out>/determinism/capture-logs/`,
dropping only the leading dot, so `capture-determinism-3.log` in the artifact is the
`.capture-determinism-3.log` the failing step named on screen. Copied rather than
added to `path:` deliberately: the glob would force `include-hidden-files: true`,
and that flag is not selective — it would also sweep in each run's throwaway
`.appdata` (SQLite DB + app logs) from inside the tree being uploaded. Copying keeps
that default exclusion doing its job, and puts the evidence where a plain `ls` on
the unzipped artifact shows it, rather than under a name `ls` hides — a trap this
README already warns readers about.

The step is the only place that knows `run_capture`'s log naming, so it refuses to
fail silently: no match emits a `::warning::` and writes a `NO-CAPTURE-LOGS.txt`
into the artifact naming the glob it looked for and the step to update.

Nothing about the gate itself moves. Five runs, byte identity, and the independent
18-screen assertion are all unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9496201

…e hoisted

The collect step makes three copies of this path in one job. The obvious tidy-up — a job-level env: — silently does not work, because the runner context is not available to jobs.<job_id>.env (only to steps). Name both facts where someone would try it.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c6dfa35

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green apart from the documented #202 dependency — ready for review.

Qodo on c6dfa35: 0 bugs / 0 rule violations / 0 requirement gaps, 0 inline threads, re-checked on both surfaces after settling. Every check is green except Desktop head on macOS, which is red only on the #201 flip that #202 fixes — verified, not assumed: this branch merged with #202 goes green on macos-latest at the gate's real 5-run default in runs 33669480144 and 33670132486.

The failure-artifact fix is proven in both directions on real runs, artifacts downloaded and listed rather than inferred from step status: a failure before any PNG is written now uploads an artifact whose entire contents are capture-logs/capture-determinism-1.log carrying the crash trace (33669444638, 4,903 bytes — that case previously uploaded nothing at all), and a passing run still skips both the collect step and the upload. The throwaway .appdata stays excluded, which is why the logs are copied rather than swept in with include-hidden-files: true.

Merge after #202, or the gate goes red again on main.

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.

ci: gate the macOS capture harness on --determinism, the half we can prove today

1 participant