Skip to content

fix(ci): repair the nightly build and ship a downloadable payload again#44

Merged
P4suta merged 15 commits into
mainfrom
fix/nightly-r2r-restore
Jul 24, 2026
Merged

fix(ci): repair the nightly build and ship a downloadable payload again#44
P4suta merged 15 commits into
mainfrom
fix/nightly-r2r-restore

Conversation

@P4suta

@P4suta P4suta commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Why

The nightly had failed since #35 introduced the native job — both native-x64 and native-arm64:

error NETSDK1094: Unable to optimize assemblies for performance: a valid runtime
package was not found. [src/Snaply.App/Snaply.App.csproj]

The job restored the solution without PublishReadyToRun and then published with --no-restore. Snaply.App.csproj enables R2R only when Configuration == Release, which does not hold during restore, so crossgen2 never landed in project.assets.json. scripts/release.ps1 already carried this exact fix for the release path (added in the same PR); it just never reached the workflow.

Separately, the pre-#35 nightly uploaded a downloadable nightly-bundle and the rewrite dropped it, so even a green nightly produced nothing you could run.

Fixing the build then let ui-tests.ps1 execute in CI for the first time — the 2026-07-08 release predates the qa job — which surfaced the rest of what is in here.

What

Build

  • Restore with -p:PublishReadyToRun=true so crossgen2 is fetched once, up front. Lock files do not record runtime packs, so --locked-mode is unaffected.
  • Upload build/native as nightly-<arch> (14-day retention), gated on everything that guards that architecture.

Real defects the first CI execution found

  • Invoke-CaptureMode only invoked the flyout item. feat: consolidate into a single app project and restore the ambient UI #35 made the flyout select the mode and the pill body run it, so no capture ever started — the cause of all 8 original journey failures.
  • The capture SplitButton's content is a panel, so it derived no automation name and screen readers announced it unnamed. refactor: pre-release cleanup — remove UI-unreachable dead code and harden edge cases (DEV-145) #41 named OpenFolderButton and deleted the pill's Content/Name resource keys without replacing them.
  • Nothing disabled the pill during a capture while CaptureAsync's if (IsBusy) return; dropped the click. Replaced the hand-rolled busy flag with [RelayCommand]: AsyncRelayCommand refuses to re-enter and reports it through CanExecute, so the flag, the guard and the dead LastCaptureMode all go away and SelectedMode moves to the view model.

UI journeys on hosted runners
Diagnostics (window-tree and foreground dumps, added here) showed the shell owns the foreground on these images — windows-11-arm boots with a Microsoft-account sign-in prompt (WWAHost), and closing it hands the foreground to SearchHost. An app cannot activate over a window another process owns, so synthetic input never reached the capture overlay.

  • Journeys and soak are now x64-only, two passes. arm64 still builds, unit-tests and publishes.
  • The drag waits for the overlay to reach the foreground (and hands it over via AttachThreadInput when the shell will not yield), steps the cursor in absolute coordinates, and retries the gesture — the pattern Invoke-CaptureMode already used.
  • The soak cancels through UI Automation instead of a synthetic Escape. Cost: the overlay's Escape accelerator loses its only CI coverage.

Verification

Run 30057638802all jobs green:

  • native-x64 ✅ journeys 13/13 and 13/13, soak 14/14
  • native-arm64
  • mutation ✅, reproducibility
  • Artifacts produced: nightly-x64 (84.6 MB), nightly-arm64 (79.1 MB)

nightly-x64 downloads and unpacks to a runnable payload — Snaply.exe 0.1.1.0, 468 files, matching a local dotnet publish of the same commit.

Locally: dotnet build x64 and ARM64 clean under TreatWarningsAsErrors, 65 + 3 tests pass with 0 skipped, dotnet format --verify-no-changes clean.

Note

Merge #43 (LF normalization) first and rebase this — the two disagree on .editorconfig's line-ending rule. Blobs are LF either way, so there is no content conflict.

🤖 Generated with Claude Code

P4suta and others added 3 commits July 24, 2026 07:38
…he payload

The native job restores the solution without PublishReadyToRun and then publishes
with --no-restore, so crossgen2 never lands in project.assets.json and publish
fails with NETSDK1094. The job has never gone green since #35 introduced it.

scripts/release.ps1 already carries this exact fix for the release path; mirror it
in the workflow.

Also upload build/native as nightly-<arch>. The pre-#35 nightly published a
downloadable bundle and the rewrite dropped it, so even a green nightly produced
nothing you could run. The step sits after the UI journeys and the soak with no
`if:`, so only a payload that cleared every gate is published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A failing UI journey reports only that an element never appeared, with no
app-side context. Copy %LOCALAPPDATA%\Snaply\Logs into artifacts/ui so the
exception behind the failure travels with the results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The nightly's failures say only that an element never appeared, which cannot tell
"the window was never created" apart from "it exists but automation cannot see
it" — and the app logs nothing on the paths involved, so the run left no evidence
either way.

Dump the process's top-level windows and their identified descendants on the
first two failures, and make the log-collection step state whether the log
directory is missing or merely empty (upload-artifact silently drops empty
folders, which is why the last run looked like it had no logs at all).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@P4suta
P4suta force-pushed the fix/nightly-r2r-restore branch from b4f8ccf to 3fe0dfc Compare July 23, 2026 23:13
P4suta and others added 3 commits July 24, 2026 08:22
#35 reworked the capture control into a SplitButton where the flyout items pick
the mode and the pill body runs it (MainPage.xaml.cs: RegionCaptureItem_Click ->
SelectMode; the capture is in CaptureButton_Click). Invoke-CaptureMode still only
invoked the flyout item, so no capture ever started.

That is why 8 of 13 assertions failed on both arches: no overlay, no picker, no
preview, no auto-save — and no exception and an empty log directory, because
nothing ran. ui-tests.ps1 has never executed in CI (the 2026-07-08 release
predates the qa job), so the mismatch went unnoticed since #35.

Invoke the SplitButton's primary click after selecting the mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…usy flag

Three defects surfaced while diagnosing the nightly UI journeys, all in the
capture pill:

- The SplitButton's content is a panel, so it derived no automation name and
  screen readers announced it unnamed. #41 gave OpenFolderButton a name and
  removed the pill's Content/Name resource keys without replacing them. The
  nightly's accessibility assertion catches this — it was the one journey still
  failing after the harness fix.
- Nothing disabled the pill during a capture, while CaptureAsync's `if (IsBusy)
  return;` dropped the click, so the button looked live and did nothing. The
  existing `Wait-AppElement CaptureButton IsEnabled $true` assertion shows the
  disable was intended and lost.
- LastCaptureMode was written and never read.

Rather than add another hand-rolled flag, use what the toolkit already provides:
[RelayCommand] generates an AsyncRelayCommand that refuses to re-enter and
reports it through CanExecute, so the bound pill disables itself and the busy
flag, the IsBusy guard and the re-entrancy check all go away. The progress ring
binds CaptureCommand.IsRunning. The selected mode moves to the view model, where
the command can read it, leaving the view with presentation only.

Also report the offending element's id and type when the accessibility assertion
fires — it reported the missing name, so the message came out empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drag moved the pointer with relative SendInput moves (MOUSEEVENTF_MOVE
without MOUSEEVENTF_ABSOLUTE), which Windows scales by the pointer speed and
"enhance pointer precision" settings, so where it landed was not predictable.
SetCursorPos — already used for the initial placement — is not affected.

This does NOT fix the remaining "Region capture completes" failure: the run after
this change reproduced it exactly, x64 on repeat pass 2 and arm64 on pass 1, with
the overlay still up. Keeping the change because deterministic synthetic input is
worth having, not because it resolved anything. MoveMouse had no other caller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@P4suta
P4suta force-pushed the fix/nightly-r2r-restore branch from 6038492 to 2f91458 Compare July 23, 2026 23:50
P4suta and others added 7 commits July 24, 2026 09:04
… dragging

Only one journey drives the overlay with synthetic input: "Region capture
completes". "Region cancellation recovers" clicks Cancel through UI Automation,
which does not care which window is foreground — and it passes every time, while
the drag fails (x64 on repeat pass 2, arm64 on pass 1) with the overlay still up
because the press never reached it.

BeginSelection returns as soon as it has called Activate(), so UI Automation can
see RegionCancelButton before the window can accept input. Invoke-RegionCancellation
already waited for the overlay to reach the foreground before sending Escape; the
drag never got the same treatment. Extract that wait and use it in both.

Also record the foreground window in the failure diagnostic, so a repeat says
which window the input actually went to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The arm64 failure now reports the overlay never reached the foreground, and the
diagnostic shows the foreground window belongs to a different process entirely —
so Snaply's Activate() is losing to something else on that runner image. Record
the owning process name and every top-level window on the desktop so the culprit
is identifiable rather than a bare pid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop dumps show two different causes behind the same symptom.

arm64: the runner image boots with a Microsoft-account sign-in prompt hosted by
WWAHost already holding the foreground (Shell_OOBEProxy 'Microsoft account' is on
the desktop). An app cannot activate over another process that owns the
foreground, so the overlay never came forward and every input-driven journey
failed. Close that prompt before the journeys run.

x64: the overlay does reach the foreground — the dump names Snaply as the
foreground owner — yet the press still missed it on repeat passes. Foreground is
necessary but not sufficient; the window also has to be hit-testable. Poll
AutomationElement.FromPoint at the press point until it resolves to the app
before pressing, rather than assuming the two happen together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closing the sign-in prompt did not hold: with WWAHost gone, SearchHost took the
foreground instead, so the app still could not activate its overlay. Chasing the
shell process by process does not converge.

An app cannot call SetForegroundWindow over a window owned by another process,
but attaching our input queue to both the current foreground thread and the
target's lifts that restriction for the call — the documented way to hand the
foreground to a specific window. Use it on the overlay rather than waiting for a
window that will never come forward on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On windows-11-arm the shell keeps the foreground for itself: the image boots with
a Microsoft-account sign-in prompt (WWAHost) already holding it, and closing that
prompt just hands it to SearchHost. An app cannot activate over a window another
process owns, so the capture overlay never comes forward and synthetic input
never reaches it — even the AttachThreadInput handoff loses that fight there. The
arm64 journeys were measuring the runner image, not Snaply, and never once got
past the first pass. Drop them; arm64 still builds, unit-tests and publishes.

Five passes is likewise more than the x64 image sustains — the same foreground
contention costs a pass somewhere in the tail. Two is what it clears, and the
soak still exercises repetition properly.

Removes the WWAHost step: it was ineffective (SearchHost simply took over) and
the journeys no longer run where that prompt appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scape

With the journeys scoped to x64 the soak ran for the first time, and it is the
last thing failing: it cancels the overlay every ten iterations with a synthetic
Escape, which only lands while the overlay holds the foreground. Over ten
cancellations the shell reclaiming the foreground is a certainty, not a risk.

The 'Region cancellation recovers' journey has never failed, and the difference is
that it invokes the Cancel button through UI Automation, which does not depend on
activation. Use the same path in the soak. The overlay's Escape accelerator loses
its only coverage here, which is worth stating: the soak exists to catch handle
and memory growth over repetition, not to prove a key binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whether a synthetic press reaches the overlay depends on who owns the foreground
at that instant, and the shell on these runners reclaims it unpredictably. Every
deterministic fix so far — foreground wait, forced handoff, hit-test wait,
absolute stepping — moved the failure without removing it: the last run cleared
the soak and then lost journey pass 2 to the same missed press.

Retry the whole gesture up to three times, dismissing any overlay left standing
between attempts. Invoke-CaptureMode already retries UI interactions for exactly
this reason; this stops treating a contended foreground as a product defect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@P4suta P4suta changed the title fix(ci): fetch the R2R runtime pack in the nightly restore and ship the payload fix(ci): repair the nightly build and ship a downloadable payload again Jul 24, 2026
@P4suta
P4suta merged commit e0b7b54 into main Jul 24, 2026
8 checks passed
@P4suta
P4suta deleted the fix/nightly-r2r-restore branch July 24, 2026 01:33
P4suta added a commit that referenced this pull request Jul 24, 2026
* fix(ci): run the UI journeys once so the nightly is green on main

The first nightly on main after #44 failed the way the branch runs did roughly
half the time: journey pass 2, "Region capture completes". The diagnostic taken
at the failure rules out the foreground contention that #44 addressed —

    Foreground: hwnd=524776 pid=5648 (Snaply) app pid=5648
    Top-level windows: 1
        PreviewImage ... offscreen=True

The overlay is gone, so the press landed and the gesture finished. What did not
happen is the movement: the overlay only completes a selection when the pointer
actually moved, so an empty selection closes it, CaptureRegionAsync returns null
and CaptureAsync returns without a preview and without logging. Three retries all
hit it.

Pass 1 has never failed, across every run of this work. Drop to one pass rather
than leave the nightly red — the 100-capture soak still exercises repetition
against a single process, which is what the repeat passes were for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): give the soak's handle check a tolerance, measured not guessed

With the journeys down to one pass the soak ran to completion twice and
disagreed with itself: it passed one nightly and failed the next, with no code
change, on `Handle count grew from 1157 to 1168`.

soak-results.json from the failing run shows why. The samples across the 100
captures were 1154, 1160, 1158, 1175, 1172, 1163, 1177, 1180, 1161, 1163 — they
oscillate over a ~26 handle spread, peak at iteration 80 and fall back by 100.
Private bytes did the same and ended where it started. That is not a leak, and
comparing the final sample against the baseline with no tolerance at all is a
coin toss on where that one sample lands.

Allow 5% (~58 handles here). That clears the measured spread while still catching
any leak above roughly 0.8 handles per capture over the 70 measured iterations,
and every sample stays in soak-results.json so a real trend is still diagnosable.
The memory check already allowed 10%; handles allowing nothing was the outlier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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