ci: verify the published binary reports the tag before anything is published - #1929
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.ps1injectsVersion,FileVersionandAssemblyVersionfrom the tag, and nothingdownstream read them back.
ci.yml:98-104checks that the three csproj values agree with eachother, 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:264comparesUpdateService.CurrentVersionagainstthe 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 readablewithout running anything:
FileVersionmust equal<tag>.0, andProductVersion(informational,which SourceLink suffixes with
+<sha>on aContinuousIntegrationBuild) must equal the tag beforethe
+. Placed right after the rename and before the SBOM and the attestation — attesting is asigned 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.GetAssemblyNamethrows on a single-file apphost (verified locally). The only place itis 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:
LogServicelogged a bare"SysManager started". It now logs the version from the same source About and the updater read. Sidebenefit, 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:)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
maindotnet format --verify-no-changespasses on both touched projectsclaim.
ARCHITECTURE.mddoes not enumerate release steps andTESTING.mdcovers the threetest projects, so neither has a home for this.
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:
ProductVersionverdict downgraded toWrite-Hostcontinue-on-error: trueon the stamp stepFileVersionverdict commented out (prose still names it)StartupMessagereverted to the unversioned line{Message:lj}→{Message:j}(version comes out quoted)UpdateService.CurrentVersion2. 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:9.9.9checked as9.9.9→ exit 01.65.19→ exit 1,FileVersion driftFileVersion 9.9.9.0butVersion 8.8.8, checked as9.9.9→exit 1,
ProductVersion driftThe 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 againstlog fixtures: matching version → exit 0; drifted version →
Runtime version drift; log with nostartup line → fails; no log at all → fails.
Local proof runs used Windows PowerShell 5.1 (
pwshis not installed on that machine). Theconstructs 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.