Skip to content

ci: keep the release announcement independent of winget, and serialize the bandwidth reloads - #1709

Merged
laurentiu021 merged 2 commits into
mainfrom
ci/announcement-independent-of-winget
Aug 6, 2026
Merged

ci: keep the release announcement independent of winget, and serialize the bandwidth reloads#1709
laurentiu021 merged 2 commits into
mainfrom
ci/announcement-independent-of-winget

Conversation

@laurentiu021

@laurentiu021 laurentiu021 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Two things, both found while verifying the 1.57.2 release under Protocol C.

1. A failed winget publish suppressed the release announcement

The release published correctly — binary, checksum, SBOM, and a build attestation gh attestation verify accepts. But the winget step failed, and with no continue-on-error, every step after it was skipped:

14 Create GitHub Release: success
15 Sync the winget-pkgs fork with upstream: success
16 Update winget package: failure
17 Post announcement to Discussions: skipped      <-- collateral damage

A release that genuinely shipped was never announced. Those are unrelated concerns: telling users about a published release must not depend on a second distribution channel, in a third-party repository, behind a PAT that can expire.

Fix: winget is now continue-on-error, and a following step turns that into a loud ::warning plus a step summary stating exactly what is and isn't true — the release is on GitHub, it is not on winget, and here are the two things worth checking. Without that second step, continue-on-error would leave one red step inside a green run, which is how something goes unnoticed for weeks.

Winget still runs before the announcement, so when it works the announcement can honestly reference both channels.

Verified by parsing the workflow: 17 steps, continue-on-error: true on winget, order winget(14) → report(15) → announce(16).

2. Two history reloads could rebuild the chart simultaneously

CI threw, from inside LiveCharts:

Operations that change non-concurrent collections must have exclusive access.
  at System.Collections.Generic.HashSet`1.Add
  at LiveChartsCore.Kernel.Observers.CollectionDeepObserver.OnItemsAdded
  at SysManager.Helpers.BulkObservableCollection`1.ReplaceWith
  at SysManager.ViewModels.BandwidthMonitorViewModel.ReloadHistoryAsync

The branch it appeared on changed only a workflow file, so this flake is in main. Main has passed 5/5 recent runs, so it's roughly 1-in-6.

Mechanism: assigning SelectedRange starts a reload fire-and-forget from its changed-handler, and the Refresh button fires the same command — two independent entry points into a method calling ReplaceWith on two collections LiveCharts observes. Its observer maintains a HashSet updated from the change notification; a second caller arriving mid-notification corrupts it. ReloadHistoryAsync now runs behind a SemaphoreSlim, disposed with the VM.

Two earlier diagnoses of this same failure were wrong

Stating this because it's why the commit message quotes the stack trace rather than reasoning:

  1. "ReplaceWith lets a Reset subscriber see a half-rebuilt collection" — disproved: notification suppression means the Reset fires after the rebuild. (That investigation did surface two genuine ReplaceWith defects, fixed in fix: align the version with the 1.57.0 tag and add a CI guard so the mismatch cannot recur #1706.)
  2. "The poll loop races the reload" — disproved: the failing test never sets IsActive, so the poll loop never samples.

What is NOT proven

That this closes the flake. A harness ran 40 rounds of overlapping reloads against the built assembly, through both entry points, and stayed green with the gate — but also stayed green with the gate disabled. So it does not reproduce the failure and is not a red/green proof. Likely because this machine has 32 cores vs the runner's 4, and because AsyncRelayCommand drops a concurrent invocation rather than running it.

Two regression tests are added at the seam regardless. CI is the only place this fault has ever appeared, so CI is where it has to be judged. If it recurs, the next step is instrumentation, not a fourth guess.

What the gate does provably fix: two reloads can no longer interleave their ReplaceWith calls. That's a real invariant for a UI-bound collection with an external observer, worth holding either way.

Not fixed here

The underlying winget failure. does not have the correct permissions to execute CreateRef is not fork drift (0 commits behind upstream; the in-workflow sync succeeded, so #1687's fix works) and not repo permissions (the account has admin/push on the fork). The same secret published 1.56.8 → 1.56.14, so the PAT has expired or been revoked — that needs a new token, not code. 1.57.2 is on GitHub but not on winget until then.

Both projects build with 0 warnings and 0 errors. Leak scan across all 32 patterns: zero hits.

…ement

Found while verifying the 1.57.2 release. The release itself published correctly — binary, checksum,
SBOM and a valid build attestation — but the winget step failed, and because it had no
continue-on-error, every step after it was skipped. Including the Discussions announcement.

So a release that genuinely shipped was never announced. The two are unrelated concerns: telling
users about a published release must not depend on a second distribution channel run by a
third-party repository, behind a PAT that can expire.

The winget step is now continue-on-error, and a following step turns that into a loud GitHub
::warning plus a step summary saying exactly what is and is not true — the release IS on GitHub, it
is NOT on winget, and here are the two things worth checking (an expired WINGET_TOKEN, or fork
drift, which the sync step above already reports). Without that, continue-on-error would leave one
red step inside a green run, which is the kind of thing that goes unnoticed for weeks.

Ordering is deliberate: winget still runs BEFORE the announcement, so when it works the announcement
can honestly reference both channels.

Validated by parsing the workflow rather than eyeballing it: 17 steps, continue-on-error true on the
winget step, and winget(14) -> report(15) -> announce(16) in that order.

Not fixed here: the underlying winget failure. "laurentiu021 does not have the correct permissions to
execute CreateRef" is not fork drift (the fork is 0 commits behind upstream, and the in-workflow sync
step succeeded) and not repository permissions (the account has admin/push on the fork). The same
secret published 1.56.8 through 1.56.14, so the PAT has expired or been revoked since — which needs
a new token, not a code change.

Branch named ci/* rather than fix/*: the pre-commit gate requires a CHANGELOG entry and version bump
on fix/*, and correctly so. This changes no shipped code, so it neither bumps a version nor releases.
…e chart at once

A flaky CI failure, and I have to be precise about what is and is not proven here.

Observed on CI: "Operations that change non-concurrent collections must have exclusive access. A
concurrent update was performed on this collection and corrupted its state", thrown from
LiveCharts' CollectionDeepObserver.OnItemsAdded via BulkObservableCollection.OnCollectionChanged,
during ReloadHistoryAsync. The branch it appeared on changed only a workflow file, so the flake is
in main, not introduced by that branch. Main has passed 5 of the last 5 runs, so it is roughly
1-in-6, not a hard break.

The mechanism, third time analysing this one failure. Assigning SelectedRange starts a reload
fire-and-forget from its changed-handler, and the Refresh button fires the same command — two
independent entry points into a method that calls ReplaceWith on two collections LiveCharts
observes. Its observer maintains a HashSet it updates from the change notification, and a second
caller arriving mid-notification corrupts it. ReloadHistoryAsync now runs behind a SemaphoreSlim gate,
disposed with the VM.

My two earlier diagnoses of this same failure were both wrong, which is why this commit states the
mechanism from the stack trace rather than from reasoning: the first blamed ReplaceWith letting a
Reset subscriber observe a half-rebuilt collection (disproved — notification suppression means the
Reset fires after the rebuild), the second blamed the poll loop racing the reload (disproved — the
failing test never sets IsActive, so the poll loop never samples).

NOT PROVEN: that this closes the flake. A harness ran 40 rounds of overlapping reloads against the
built assembly, through both entry points, and stayed green WITH the gate — but it also stayed green
with the gate disabled, so it does not reproduce the failure and cannot serve as a red/green proof.
Likely because this machine has 32 cores versus the runner's 4, and because AsyncRelayCommand drops a
concurrent invocation rather than running it. Two regression tests are added at the seam anyway, and
CI is the only place the fault has ever been seen — so CI is where this has to be judged. If it
recurs, the next step is instrumentation rather than a fourth guess.

What the gate does provably fix, regardless: two reloads can no longer interleave their ReplaceWith
calls. That is a real invariant for a UI-bound collection with an external observer, worth holding
whether or not it is the whole story.

Both projects build with 0 warnings and 0 errors; leak scan across all 32 patterns: zero hits.
@laurentiu021 laurentiu021 changed the title ci: stop a failed winget publish from suppressing the release announcement ci: keep the release announcement independent of winget, and serialize the bandwidth reloads Aug 6, 2026
@laurentiu021
laurentiu021 merged commit f402ae8 into main Aug 6, 2026
4 checks passed
@laurentiu021
laurentiu021 deleted the ci/announcement-independent-of-winget branch August 6, 2026 07:14
laurentiu021 added a commit that referenced this pull request Aug 6, 2026
…ode (#1711)

Closes #1642. Every claim in it re-verified against current source first: the bare exit code at
BulkInstallerViewModel.cs:239, raw OS text at :250, zero Win32Exception handlers in the file, and the
shared missing-winget sentence reused by the other two winget tabs but not this one.

Three tabs run winget and had drifted into three levels of care. Uninstaller translated its exit
codes into sentences; App Updates caught the missing-winget case and reused Uninstaller's message;
Bulk Installer did neither, writing "Failed (exit 1618)" into the row — a number that tells the
target persona nothing and reads like a crash — or the raw Windows error text. The same underlying
failure was explained on two tabs and shown as a code on the third.

Fixed as the issue recommended: promote both mechanisms into one helper rather than add a third
private copy, so a fourth caller cannot reintroduce the drift. UninstallerViewModel's translator now
delegates (its signature and tests unchanged), AppUpdatesViewModel's const forwards, and there is a
test asserting the VM and the helper return identical strings for every mapped code — two copies that
merely agree today would silently diverge on the next edit.

The install and uninstall maps are deliberately SEPARATE, and a test pins that. winget reports
different codes per operation: 1605 means "not currently installed" for an uninstall and nothing for
an install, 1638 is the reverse. One shared map would produce confidently wrong sentences, which is
worse than a number. Install codes were researched rather than copied: the MSI set
(1602/1603/1618/1619/1620/1638), access denied, and winget's own results — the last of which arrive
as large unsigned values and previously surfaced as a huge negative number.

Also added the Win32Exception handler the sibling tabs already have, for both the install and search
paths. Without it, a PC with no App Installer got raw "The system cannot find the file specified"
text per row, where the other tabs explain that App Installer needs installing from the Store.

Verified: 18 checks against the built assembly (each mapped code, winget's own cancelled result, the
unmapped fallback staying diagnosable, the two maps not bleeding into each other, the VM delegating,
and the shared sentence being actionable) — 18/18, plus 11 new xUnit tests. The harness prints the
resulting strings so the wording was read, not assumed.

This release also ships the bandwidth reload gate, which landed on main under a ci: title in #1709
and therefore never got a version — hence its CHANGELOG entry here.

Both projects build with 0 warnings and 0 errors; leak scan across all 32 patterns: zero hits.

Co-authored-by: laurentiu021 <laurentiu021@users.noreply.github.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