Skip to content

ci: verify the published binary reports the tag before anything is published - #1929

Merged
laurentiu021 merged 1 commit into
mainfrom
ci/verify-published-version-stamp
Aug 19, 2026
Merged

ci: verify the published binary reports the tag before anything is published#1929
laurentiu021 merged 1 commit into
mainfrom
ci/verify-published-version-stamp

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

What does this PR do?

Closes the second half of #1658. The first half shipped in #1885 — the release job now launches the
exe and proves it starts. Nothing ever checked what version that exe reports.

publish.ps1 injects Version, FileVersion and AssemblyVersion from the tag, and nothing
downstream read them back. ci.yml:98-104 checks that the three csproj values agree with each
other
, which says nothing about the artifact. A silently broken injection publishes a binary that
misreports itself in About, in the bug-report URL, in the profile export and in the system report —
and in the update check, where AboutViewModel:264 compares UpdateService.CurrentVersion against
the newest release, so a stale stamp offers every user the same update forever or hides a real one.

Two different values are at risk, so there are two assertions.

Static — new step, Verify the embedded version stamp. The Win32 version resource is readable
without running anything: FileVersion must equal <tag>.0, and ProductVersion (informational,
which SourceLink suffixes with +<sha> on a ContinuousIntegrationBuild) must equal the tag before
the +. Placed right after the rename and before the SBOM and the attestation — attesting is a
signed public claim about a specific binary, and making it about a mis-stamped one cannot be retracted.

Runtime — inside the existing smoke check. The managed assembly version is the one every
user-visible version actually reads, it is not in the Win32 resource, and
AssemblyName.GetAssemblyName throws on a single-file apphost (verified locally). The only place it
is observable is a running process, so the startup line is read out of the log of the launch the
smoke check already performs and compared to the tag. A missing log, or a missing startup line, fails
the job — an absent line is otherwise indistinguishable from a matching one.

That needed the startup line to carry a version at all, which it did not: LogService logged a bare
"SysManager started". It now logs the version from the same source About and the updater read. Side
benefit, and the reason it is worth doing beyond the gate: an attached support log finally names the
build it came from.

Related issues

Closes #1658

Type of change

  • CI / build (ci:)

Same classification as the sibling change that added the smoke check (ci: run the exe before publishing it, #1885). No release, so no CHANGELOG entry and no version bump.

Checklist

  • Branch created from main
  • Code compiles with 0 errors — all four projects, 0 warnings
  • dotnet format --verify-no-changes passes on both touched projects
  • Tests added/updated and passing locally
  • Author headers on all modified files
  • Self-review completed
  • README updated (if features changed) — n/a: no new service/VM/file, no tab change, no count
    claim. ARCHITECTURE.md does not enumerate release steps and TESTING.md covers the three
    test projects, so neither has a home for this.
  • No AI/IDE tool references

How this was verified

Three proofs, because a guard that only checks the gate is present cannot show it works.

1. The guard catches every way of weakening the gate — 8 mutations, each expected test red, all
files restored byte-for-byte afterwards:

Mutation Red
stamp step moved to just before the announcement guard
ProductVersion verdict downgraded to Write-Host guard
continue-on-error: true on the stamp step guard
whole FileVersion verdict commented out (prose still names it) guard
runtime assertion deleted from the smoke check guard
StartupMessage reverted to the unversioned line guard + render test
{Message:lj}{Message:j} (version comes out quoted) render test
call site passes a literal instead of UpdateService.CurrentVersion guard

2. The shipped static check discriminates on real binaries. The step body is extracted verbatim
from release.yml — never a retyped copy — and run against published artifacts:

  • a binary stamped 9.9.9 checked as 9.9.9 → exit 0
  • the same binary presented as 1.65.19 → exit 1, FileVersion drift
  • a binary deliberately built with FileVersion 9.9.9.0 but Version 8.8.8, checked as 9.9.9
    exit 1, ProductVersion drift

The third case is what proves the second verdict is not dead code.

3. The shipped runtime check discriminates. The version block is extracted from the smoke step
(asserted to contain no Start-Process, so the extraction cannot launch the app) and driven against
log fixtures: matching version → exit 0; drifted version → Runtime version drift; log with no
startup line → fails; no log at all → fails.

Local proof runs used Windows PowerShell 5.1 (pwsh is not installed on that machine). The
constructs used are identical in both hosts, and the em-dashes in the new strings follow the pattern
already shipping in this file (release.yml:41,51,54,102,221,558,672).

Deliberately not in scope

The workflow still does not re-verify its own published SHA256 or its attestation in-job. Both are
covered by the manual post-release ritual; folding them in is a separate change.

…blished

publish.ps1 injects Version, FileVersion and AssemblyVersion from the tag, and
nothing downstream ever read them back. ci.yml only checks that the three csproj
values agree with EACH OTHER, which says nothing about the artifact, so a
silently broken injection would publish a binary that misreports itself in About,
in the bug-report URL, in the profile export and in the system report — and in
the update check, where AboutViewModel compares UpdateService.CurrentVersion
against the newest release, so a stale stamp offers every user the same update
forever or hides a real one.

Two values are at risk, so there are two assertions.

The Win32 version resource is readable without running anything, so a new step
checks FileVersion and ProductVersion right after the exe is named — before the
SBOM and before the attestation, because attesting is a signed public claim about
a specific binary and making it about a mis-stamped one cannot be taken back.

The managed assembly version, which is what every user-visible version actually
reads, is NOT in that resource and AssemblyName.GetAssemblyName throws on a
single-file apphost. The only place it is observable is a running process, so it
is asserted inside the smoke check that already launches the exe: the startup
line is read out of the log and compared to the tag. A missing log or a missing
startup line fails the job rather than passing quietly, since an absent line is
otherwise indistinguishable from a matching one.

That required the startup line to carry the version at all, which it did not:
LogService logged a bare "SysManager started". It now logs the version from the
same source About and the updater read, which also means an attached support log
finally names the build it came from.

Guarded by TheReleaseWorkflow_ProvesThePublishedBinaryReportsTheTag, which
derives the expected log pattern FROM LogService.StartupMessage so the workflow
and the app cannot drift into a gate that greps for a line nobody writes, and by
TheStartupLine_RendersTheVersionUnquoted, which pins the rendered shape the gate
matches on.
@laurentiu021
laurentiu021 merged commit d5a3ed1 into main Aug 19, 2026
5 checks passed
@laurentiu021
laurentiu021 deleted the ci/verify-published-version-stamp branch August 19, 2026 01:54
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.

[Enhancement]: Release - Publishes the .exe to GitHub + winget without ever verifying the binary starts or reports the right ve...

1 participant