fix(cli): detect winget install of aspire.exe so bundle stays in the winget package dir#17919
fix(cli): detect winget install of aspire.exe so bundle stays in the winget package dir#17919radical wants to merge 6 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17919Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17919" |
|
Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
|
|
Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
|
779fea5 to
e0c6790
Compare
…winget package dir
When `aspire.exe` is installed via `winget install Microsoft.Aspire` and run for the
first time, the first-run probe failed to recognize the binary as a winget install.
The probe never stamped the `{"source":"winget"}` sidecar next to the binary, so
`BundleService.ComputeDefaultExtractDir` fell through to the sidecar-less default and
extracted the bundle to `~/.aspire/` instead of colocating it with the binary under
`%LOCALAPPDATA%\Microsoft\WinGet\Packages\Microsoft.Aspire_*\`.
Three independent bugs produced the same symptom.
Two were in `WindowsRegistryReader.MatchesAspireEntry`:
1. The reader looked up the registry value `"PortableTargetFullPath"`, but winget
actually writes the value under the name `"TargetFullPath"`. The C++ identifier
`PortableTargetFullPath` in winget-cli maps to that wire name — see
`winget-cli/src/AppInstallerCommonCore/PortableARPEntry.cpp`:
`constexpr std::wstring_view s_PortableTargetFullPath = L"TargetFullPath";`.
This is why even older single-file portable manifests wouldn't have matched.
2. The Aspire manifest is `InstallerType: zip` + `NestedInstallerType: portable`
with multiple files in the archive (`aspire.exe`, `libsodium.dll`,
`Aspire.TypeSystem.xml`). For that shape, winget's `PortableInstaller::InstallFile`
skips the per-file `TargetFullPath` ARP write (`!RecordToIndex` guard) because
archive extraction records files in the package index instead. The only path
evidence in ARP is the `InstallLocation` directory.
The third was in the calling chain: when the CLI was launched through the winget
command-alias symlink under `%LOCALAPPDATA%\Microsoft\WinGet\Links\aspire.exe`,
the first-run probe re-read `Environment.ProcessPath` internally, which on
Windows can return the link path rather than the resolved target. The matcher's
`InstallLocation` containment check then compared the link path against the
package directory under `WinGet\Packages\Microsoft.Aspire_...`, missed, and the
sidecar was never written — so even with the matcher itself fixed, every
PATH-launched invocation would still fall back. The probe now takes a
symlink-resolved real process path and both call sites resolve via
`CliPathHelper.ResolveSymlinkOrOriginalPath` before calling. The doctor-side
caller `InstallationInfoOutput.RunWingetFirstRunProbe` did not resolve at all,
and would have stamped the sidecar inside `WinGet\Links\` rather than next to
the real binary.
The live registry for a current winget install looks like:
```
HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\
Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe
WinGetPackageIdentifier = Microsoft.Aspire
WinGetInstallerType = portable
InstallLocation = C:\Users\<user>\AppData\Local\Microsoft\WinGet\Packages\Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe
InstallDirectoryAddedToPath = 1
(no TargetFullPath, no PortableTargetFullPath, no SymlinkFullPath)
```
The fix splits per-entry matching out into a pure static `WingetAspireEntryMatcher`:
- Renames the registry value lookup from `PortableTargetFullPath` to `TargetFullPath`.
- Adds an `InstallLocation`-containment fallback (case-insensitive Ordinal, with a
trailing-separator-aware boundary check that prevents `C:\Foo` from matching
`C:\FooBar\aspire.exe`) so the zip-portable shape is recognized.
- Adds a permissive `WinGetInstallerType == "portable"` gate that still tolerates
null/empty for backward compatibility with older winget builds, but rejects
`"msi"`/`"exe"`/etc.
- Keeps the existing HKCU+HKLM x64-view walk and case-sensitive
`WinGetPackageIdentifier == "Microsoft.Aspire"` match.
- Reads `WinGetPackageIdentifier` first inside the registry-walk loop and
short-circuits non-Aspire entries before reading the other three values.
Uninstall has hundreds of subkeys on a typical machine and this probe runs on
every CLI invocation without a sidecar, so the common case is one
`RegQueryValueEx` per subkey rather than four.
The factored-out matcher is now covered by 16 cross-platform unit tests
(`WingetAspireEntryMatcherTests`) over both portable shapes, the separator
boundary, case sensitivity, null/empty inputs, and the `TargetFullPath`-stale
→ `InstallLocation`-fallback path.
The matcher tests are agnostic to wire names (they call `Matches(...)` with
strings directly), so two more layers of coverage guard against the wire
format itself drifting:
- `WindowsRegistryReaderIntegrationTests` (6 Windows-only tests) write
synthetic ARP entries to HKCU under unique GUID-suffixed subkeys and run
the real reader against them, exercising the constants the reader looks
up. Verified to catch a `"TargetFullPath"` → `"PortableTargetFullPath"`
regression and an `"InstallLocation"` rename regression when the constants
are temporarily flipped.
- `WinGetRegistryShapeTests` (1 Windows-only test) builds a synthetic
zip+portable manifest, serves it over a loopback HttpListener, runs real
`winget install`/`uninstall`, and asserts on the actual ARP values winget
writes. This is the only layer that catches winget changing its wire
format.
The probe's symlink-resolution contract is locked in by two new probe-level
tests: `Run_ForwardsRealProcessPathToRegistryReader` (recording fake registry
reader; fails if the probe re-introduces an internal `Environment.ProcessPath`
read) and `Run_WritesSidecarNextToRealProcessPath_NotElsewhere` (the sidecar
location must follow the resolved path the caller supplied).
The manifest layout the matcher depends on
(`InstallerType: zip` + `NestedInstallerType: portable` +
`NestedInstallerFiles: aspire.exe`) is now asserted statically in
`PowerShell_PrepareWinGetManifest_GenerateOnly_GeneratesManifestsWithArchiveHashesAndDogfood`,
so a manifest-template change that switches install shape is caught at
inner-loop test time.
To catch silent regressions of this whole chain (registry probe → sidecar →
`ComputeDefaultExtractDir` → bundle colocation), `prepare-installer-artifacts.yml`
gains a new step that asserts on the freshly-installed CLI:
- `aspire doctor --format json` reports `installations[].route == "winget"` for the
running install.
- The `.aspire-install.json` sidecar exists next to the binary with
`source=winget`.
- The bundle directory lives under the winget install location, not under
`~/.aspire/`.
- The fallback `~/.aspire/bundle` directory does not exist. A regression that
populated both locations (for example a stale winget dir left from a prior
run plus a fresh fallback extraction) would otherwise pass on the
positive-existence assertions alone.
- The verify script is run with `-ExpectedVersion` parsed from the staged
installer manifest's `PackageVersion`, so a self-hosted runner with a
pre-existing machine-wide `aspire` on PATH cannot silently pass with the
wrong binary.
The verify script's failure-dump loop uses
`Write-Host -ForegroundColor Red` rather than `Write-Error`: under
`$ErrorActionPreference='Stop'`, `Write-Error` is terminating, so the first
failed iteration would have aborted the loop and lost the diagnostic dump
before `exit 1`.
The pre-existing smoke step passed regardless of where the bundle ended up, which
is why this regression slipped through CI.
Test hygiene for the new layers:
- `WindowsRegistryReaderIntegrationTests` reaps orphan
`Microsoft.Aspire_AspireCliTests_*` subkeys from
`HKCU\...\Uninstall` on first use, so a crashed prior run does not leave
"Aspire CLI (... test) 0.0.0" entries in Settings → Apps on developer
machines. `tests/Aspire.Cli.Tests/Acquisition/cleanup-test-arp-entries.ps1`
lets devs purge a polluted machine by hand.
- `WinGetRegistryShapeTests` probes elevation up-front via
`WindowsPrincipal`. When the test is not running elevated and
`LocalManifestFiles` is not already on, it skips with an actionable
message instead of failing later with the cryptic
`0x8a150004 "Opening manifest failed"`. Loud-fail on elevated CI runners
is preserved.
Out of scope: the winget manifest could additionally set
`ArchiveBinariesDependOnPath: true` to nudge winget toward an alias/symlink layout,
which would make the path more obvious. That's a separate change. Cross-route
install/update/uninstall integration tests (mixing winget + script +
dotnet-tool) are tracked separately in microsoft#17916.
Verified end-to-end on Windows against a real `winget install Microsoft.Aspire`:
before the fix `route` is empty and the bundle extracts to `~/.aspire/bundle`;
after the fix `installations[0].route == "winget"`, the sidecar
`{"source":"winget"}` is stamped next to the winget aspire.exe (both when the
CLI is launched directly and through the WinGet\Links alias), and bundle
extraction colocates with the binary.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e0c6790 to
c91498f
Compare
WinGetRegistryShapeTests.WinGet_Install_Of_PortableZip_Writes_ARP_Values_Matcher_Reads failed on the Acquisition (windows-latest) job with: 0x8a150004 : Opening manifest failed The synthetic singleton manifest the test writes was missing the required PackageLocale field. The singleton schema's top-level "required" array lists it alongside the other required fields: PackageIdentifier, PackageVersion, PackageLocale, Publisher, PackageName, License, ShortDescription, Installers, ManifestType, ManifestVersion See https://github.com/microsoft/winget-cli/blob/master/schemas/JSON/manifests/v1.6.0/manifest.singleton.1.6.0.json. PackageLocale is easy to forget on a hand-rolled singleton because the multi-file shape (used by the real Aspire manifest) carries it in a separate *.locale.<tag>.yaml. winget rejects the manifest at the schema layer before any install/download attempt, which is why the test failed in ~4s with no install activity in the log. Also pass --accept-source-agreements and --disable-interactivity to the finally-block uninstall. winget's uninstall path touches its configured sources (e.g. msstore) which can prompt for a one-time source agreement, and this test process has no stdin — the prompt then fails with 0x8A150042 "Error reading input in prompt" and leaves the package half-uninstalled. Currently the failure is swallowed by allowFailure: true, but it noisily pollutes the test logs and would block re-runs of the test on the same machine if install had succeeded. For symmetry, the install call also gains --disable-interactivity as defense-in-depth against future winget prompt types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…on tests WindowsRegistryReaderIntegrationTests.HasWingetAspireUninstallEntry_* failed on the Cli (windows-latest) job with: System.InvalidOperationException : HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall not writable. HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall is created lazily and may not exist on a fresh user profile — observed on a GitHub Actions windows-latest runneradmin profile where nothing had yet registered a user-scope ARP entry. The test's TestUninstallEntry.Create() used OpenSubKey(writable: true), which returns null when the subkey does not exist, and the code then threw the misleading "not writable" message. Switch to CreateSubKey(writable: true), which opens-if-exists / creates-if-missing in one call. HKCU writes do not require elevation, and creating the canonical ARP parent key has no observable side effect beyond what any user-scope MSI/winget install would do. The reader (src/Aspire.Cli/Acquisition/IWindowsRegistryReader.cs) already uses OpenSubKey(writable: false) and gracefully handles a null result, so production behavior on a fresh user profile is unchanged — the reader correctly reports "no entry found" rather than failing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #17919 — Windows runbook resultsHost: Windows 11, winget v1.29.170-preview, pwsh 7.6.1, elevated.
Issues surfaced by the runbook that look like real concerns (not just runbook bugs)
Other observations
Repro commands for issues #1 and #2# Issue #1 — first-invocation route race (reproduced clean):
# After fresh install with no sidecar:
aspire doctor --format json --self | ConvertFrom-Json | % installations | % route # -> $null
aspire doctor --format json --self | ConvertFrom-Json | % installations | % route # -> "winget"
# Issue #2 — verify script asserts something the install path doesn't produce:
# Immediately after `winget install` (no aspire commands run yet besides --version from the dogfood verifier):
.\eng\scripts\verify-winget-install-detection.ps1 -ExpectedVersion 13.5.0-pr.17919.g710e1bcc
# -> exit 1, "Expected bundle dir under winget install does not exist"
# Fix: run a bundle-triggering command first.
mkdir scratch; cd scratch
aspire init --language csharp --suppress-agent-init --non-interactive
aspire restore --non-interactive
cd ..; .\eng\scripts\verify-winget-install-detection.ps1 -ExpectedVersion 13.5.0-pr.17919.g710e1bcc
# -> exit 0, all four assertions pass |
Follow-up: confirmed root cause + revised reportPer @radical's suggestion, retested with plain
The bug is specific to
// DoctorCommand.cs:70-82
if (selfOnly)
{
var self = InstallationInfoOutput.DescribeSelfSafely(_installationDiscovery, _logger);
// ... no probe ...
return CommandResult.Success();
}
var installationsTask = InstallationInfoOutput.DiscoverAllSafelyAsync(
_installationDiscovery, _wingetFirstRunProbe, _logger, cancellationToken); // <-- probe here// InstallationInfoOutput.cs:47-65 — DescribeSelfSafely takes no probe param
public static IReadOnlyList<InstallationInfo> DescribeSelfSafely(IInstallationDiscovery discovery, ILogger logger)
{
try { return [discovery.DescribeSelf()]; } // <-- reads sidecar; probe never ran
...
}Why this matters for this PR: the PR's own Suggested fix (one place): have Revised verdict on the runbook resultsWith this understood, my earlier "all green" claim about Step 3 was misleading — it passed only because I had previously run # Truly clean state (uninstall + delete ~/.aspire + remove pkg dir + ARP key + Links symlink), then:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17919 -InstallMode WinGet"
.\eng\scripts\verify-winget-install-detection.ps1 -ExpectedVersion 13.5.0-pr.17919.g710e1bcc
# -> exit 1: route missing, no bundle dir under pkg
# Same clean install, run plain doctor first:
aspire doctor | Out-Null # extracts bundle, writes sidecar, sets route
.\eng\scripts\verify-winget-install-detection.ps1 -ExpectedVersion 13.5.0-pr.17919.g710e1bcc
# -> exit 0, all four assertions passOther findings still standing (lower priority)
|
`aspire doctor --format json --self` previously skipped the winget first-run probe, so on a fresh `winget install Microsoft.Aspire` the sidecar `.aspire-install.json` was never stamped and the JSON reported `route` as null until some other CLI command (a plain `aspire doctor` without --self, `aspire restore`, etc.) happened to run the probe. This bit the PR's own regression script, `eng/scripts/verify-winget-install-detection.ps1`, which invokes `aspire doctor --format json --self`: assertions 1, 3, and 4 (route, bundle colocation, no `~/.aspire/bundle`) all failed on a clean install because the probe never ran and bundle extraction never fired. The script appeared to pass only when state was incidentally primed by an earlier command. `DescribeSelfSafely` now takes the probe and invokes it via `RunWingetFirstRunProbe` before reading the sidecar via `discovery.DescribeSelf()`, mirroring the shape of `DiscoverAllSafelyAsync`. The probe is OS-neutral at the call site (the real `WindowsRegistryReader` short-circuits on non-Windows, so the only cost on non-Windows hosts is a registry-reader virtual call that immediately returns false). The verify script also gains a `aspire doctor` priming call at the top, as belt-and-suspenders against future regressions where the --self fast-path diverges from the discovery path again. The script's .DESCRIPTION updates to reflect that callers no longer need to invoke any CLI command before running it. A new test `WingetRegistryProbeTests.DescribeSelfSafely_RunsWingetFirstRunProbe` asserts via a recording fake registry reader that the helper invokes the probe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The winget first-run probe previously bailed on `File.Exists(sidecarPath)`
with no JSON validation, so a malformed `.aspire-install.json`
(truncated atomic-rename, mid-write crash, manual edit, oversized
payload, parseable JSON missing the `source` field) wedged route
detection permanently. Every subsequent `aspire` invocation read the
sidecar as `Invalid`, `route` collapsed to null in `aspire doctor`,
and `BundleService.ComputeDefaultExtractDir` fell back to the
sidecar-less default — extracting the bundle to `~/.aspire/bundle`
instead of the winget package directory. None of this self-recovered
without the user manually deleting the sidecar.
The probe now consults `InstallSidecarReader.ReadSourceField`, which
returns the raw `source` string when the JSON parses cleanly and
contains a string-valued `source`, and `null` for missing/oversized/
malformed/empty-object cases. The guard becomes "skip iff the file
exists AND its `source` field parses cleanly". A foreign route's
parseable sidecar (`{"source":"script"}`, `{"source":"brew"}`, an
unknown future route, etc.) still parses as non-null and is left
alone — the probe owns only the winget detection lane.
`TryWriteSidecarAtomically` gains an `overwriteIfCorrupt` parameter
threaded from the self-heal branch. The cold-start branch (sidecar
missing entirely) keeps `overwrite: false` so two racing probes
cannot clobber a sidecar that another install-route's atomic writer
just landed. The self-heal branch passes `overwrite: true` because
the new guard already proved the existing file is invalid; even if a
concurrent valid writer races in, the same canonical
`{"source":"winget"}` bytes overwrite to the same content.
Cost: one `JsonDocument.Parse` per cold start when the sidecar
exists. Bounded to a single CLI invocation; the worst-case corruption
window is now at most one launch instead of indefinite.
New test coverage in WingetRegistryProbeTests:
- Run_OverwritesMalformedSidecar_WhenRegistryClaimsAspire (Theory:
raw garbage, empty object, truncated JSON) — asserts the canonical
winget payload lands.
- Run_DoesNotOverwriteForeignSidecar_EvenWhenRegistryClaimsAspire —
a `{"source":"script"}` sidecar must be preserved verbatim even
when the registry claims this is a winget install.
The existing Run_IsIdempotent_OnSecondRun test's comment updates to
reflect the refined contract: "do not touch a parseable sidecar even
if its source string is unrecognized" — its `{"source":"winget-pre-seeded"}`
payload still parses cleanly so the assertion is unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
✅ Windows live verification — greenRan a before/after matrix on a real Setup. Single physical
Also ran:
No follow-up needed before merge. Machine state restored to canonical sidecar content after the session; no orphan state from the verification itself. Full report (machine state, command outputs, repro notes)Date. 2026-06-05 Companion docs (untracked in repo root):
This doc reports what was actually run and observed during this session — nothing was deferred. TL;DRAll verifications green. The two recent fix commits behave exactly as their commit messages claim, both at the unit-test layer and on a real Windows + real winget end-to-end environment. The single most important result: a before/after live test against the same physical winget package directory, swapping in the pre-fix vs post-fix binary, reproduces the bugs on the old binary and shows them fixed on HEAD.
What changed in this PR (recap)Five commits between
This verification focused on (4) and (5), which are the only two whose runtime behavior had not been independently confirmed prior to this session. The earlier three were already CI-green on Unit-test resultsAll commands run with the local
|
| Case | Setup | Pre-fix binary (710e1bc) | Post-fix binary (HEAD, 62c6d64) |
|---|---|---|---|
A Probe runs on --self |
Sidecar deleted | route: blank ❌; sidecar gets written {"source":"winget"} but discovery had already read it as missing |
route: 'winget' ✓; sidecar {"source":"winget"} ✓ |
| B Self-heal corrupt | Sidecar = this is not json {{{ |
route: blank ❌; garbage left in place |
route: 'winget' ✓; sidecar rewritten to {"source":"winget"} ✓ |
| C Foreign route preserved | Sidecar = {"source":"script"} |
(not run pre-fix; same as post-fix by construction — probe's old guard was File.Exists) |
Sidecar untouched ✓; route: 'script' ✓ |
| D Well-formed winget idempotent | Sidecar = {"source":"winget","custom":"hello"} |
(same) | Sidecar untouched ✓ (no rewrite churn); route: 'winget' ✓ |
Why this is the most valuable single result. It is hard to fake — the pre-fix binary really fails in the exact way the two commit messages describe, and the post-fix binary really succeeds when staged inside a real winget InstallLocation so the registry probe + matcher + sidecar writer + bundle-service read all wire together. Tests A and B in particular ran against the same InstallLocation registry entry (the live ARP entry winget wrote when the machine was originally installed), so the registry-matching code path is the same in both rows of the table — only the binary changed.
Reproducing notes
- The pre-fix binary is the one winget put on disk — discovered via
Resolve-AspireBinary(follows theWinGet\Links\aspire.exesymlink) and matches version13.5.0-pr.17919.g710e1bccfromaspire --version. - The HEAD-built binary is
artifacts\bin\Aspire.Cli\Debug\net10.0\aspire.exe, the framework-dependent net10.0 layout produced bybuild.cmd. Staged whole-directory-copy into…\built\because it's framework-dependent and needs Aspire.Cli.dll etc. alongside. - The
InstallLocationregistry value points at the parent dir; the matcher's containment check (added inc91498fd7) treats…\built\aspire.exeas contained, so the probe fires and writes the sidecar at…\built\.aspire-install.json. - Cleanup:
built\removed after the test; live sidecar restored to canonical{"source":"winget"}; no orphan state.
Verify-script baseline
eng/scripts/verify-winget-install-detection.ps1 against the live install:
- All four assertions pass; exit 0
installations[0].route == "winget".aspire-install.jsonexists at the package dir withsource=wingetExpectedBundleDir(under the winget package dir) existsFallbackBundleDir(~/.aspire/bundle) does NOT exist
Confirmed in source: the priming line added by d6b0affbb (function Invoke-DoctorPriming at lines 77–89) is present and gets invoked before Get-DoctorSelfJson. The .DESCRIPTION docblock at lines 6–9 documents the new "no prior CLI command required" contract.
Out of scope (intentionally not run)
WinGetRegistryShapeTests(outerloop, real winget against a stub package). Already verified green onwindows-latestperpr-17919-winget-lifecycle-tests-handoff.md"What this session already verified" §; running locally adds no signal beyond the live end-to-end above, and would leave anotheraspiretest-winget-shape-*alias on the machine that needs the__DefaultSourcemanual cleanup.WinGetLifecycleTests(the new test suite proposed in the lifecycle handoff). Does not exist yet — that's a follow-up PR. Nothing to verify.get-aspire-cli.ps1script install + winget install side-by-side cross-route coverage. Tracked separately in test: real CLI install scenarios — cross-route mixing, side-by-side, self-update #17916, not in scope for this PR.
State of the machine after this session
- Live
Microsoft.Aspirewinget install at13.5.0-pr.17919.g710e1bcc— unchanged from pre-session. .aspire-install.jsonsidecar in the winget package dir — restored to canonical{"source":"winget"}.- Staged
built\subdir under the winget package dir — removed. - Pre-existing
aspiretest-winget-shape-16afa05a.exealias underWinGet\Links\— left in place; unrelated to this PR, it's leftover from a priorWinGetRegistryShapeTestsrun and is tracked under the__DefaultSourcecleanup work in the lifecycle handoff.
Confidence assessment
High. The combination of (a) the new unit tests added in commits (4) and (5) passing, (b) the full non-outerloop test suites for both Aspire.Cli.Tests and Aspire.Acquisition.Tests passing, (c) a clean before/after reproduction on a real winget install, and (d) the verify script passing against the live install, covers every code path the two fixes touch. No further Windows verification is needed before the PR ships.
…eck exit codes
Symptom: `Tests / Prepare WinGet installer artifacts / Prepare WinGet manifests`
failed with:
```
Priming: aspire doctor
NativeCommandExitException: D:\a\aspire\aspire\eng\scripts\verify-winget-install-detection.ps1:85
Line |
85 | $primingOutput = aspire doctor 2>&1
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Program "aspire.exe" ended with non-zero exit code: 1.
##[error]Process completed with exit code 1.
```
Root cause: the priming step in `verify-winget-install-detection.ps1`
runs a plain `aspire doctor` purely as a belt-and-suspenders side-effect
trigger (the first-run probe + bundle extraction). But that command also
runs every environment check (dev certs, container runtime, .NET SDK,
Aspire version, …) and exits 1 when ANY of them report Fail. Those
checks are unrelated to install-route detection, so an unrelated env
check failure on a CI runner aborts the verify script before any of its
route/sidecar/bundle-colocation assertions run.
The CLI log from the failing run confirms the probe + bundle extraction
fired successfully during that same `aspire doctor` invocation
(LayoutDiscovery resolved the winget layout, BundleService reported the
bundle already extracted) — only some later env check pushed exit to 1.
Two other usability bugs compound it: `$PSNativeCommandUseErrorActionPreference = $true`
throws on the non-zero exit BEFORE the manual `if ($LASTEXITCODE -ne 0)`
branch can print `$primingOutput`, so the CI log shows the throw with
no doctor output at all — no clue which check failed.
Fix: priming now ignores `aspire doctor`'s exit code (the route-detection
side effects we care about already ran by the time env checks execute)
and always echoes captured stdout + stderr to the CI log inside a fenced
block. The subsequent assertions on route, sidecar, and bundle
colocation remain the authoritative signal — a regression in install
detection still fails the script with a precise diagnostic.
`$PSNativeCommandUseErrorActionPreference` is disabled inside a script
block scope for the priming invocation so it stays enabled for the rest
of the verify script.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Symptom.
winget install Microsoft.Aspireproduces an install that doesn't recognise itself as a winget install.aspire doctor --format jsonreportsinstallations[0]without aroutefield; the install bundle extracts to~/.aspire/bundleinstead of colocating with the binary under%LOCALAPPDATA%\Microsoft\WinGet\Packages\Microsoft.Aspire_*\. The pre-existing CI smoke step passed regardless of where the bundle landed, which is why this slipped through.Root cause. Five independent bugs in the winget → ARP → sidecar → bundle chain, all only visible from a real winget install:
WindowsRegistryReaderlooked up the registry value"PortableTargetFullPath", but winget writes that value under the wire name"TargetFullPath". The C++ identifierPortableTargetFullPathinwinget-cli/src/AppInstallerCommonCore/PortableARPEntry.cppmaps to that wire string.Even with the correct wire name, the Aspire manifest (
InstallerType: zip+NestedInstallerType: portable+ multiple files in the archive) hits winget'sPortableInstaller::InstallFile!RecordToIndexbranch, which skips the per-fileTargetFullPathARP write entirely. The only path evidence in ARP is theInstallLocationdirectory.The first-run probe re-read
Environment.ProcessPathinternally, which on Windows returns the winget command-alias symlink path under%LOCALAPPDATA%\Microsoft\WinGet\Links\aspire.exerather than the resolved binary underWinGet\Packages\. The matcher'sInstallLocationcontainment check then missed.aspire doctor --format json --selfskipped the probe entirely.DescribeSelfSafelydidn't take the probe and went straight todiscovery.DescribeSelf(), which reads a sidecar the probe never had a chance to stamp.The probe's idempotency guard was
File.Exists(sidecarPath) → return. A malformed.aspire-install.json(mid-write crash, manual edit, truncated atomic-rename, oversized payload, valid JSON missing thesourcefield) wedged route detection permanently — every subsequentaspireinvocation read the sidecar asInvalidand fell back to the sidecar-less default extract dir.The live ARP shape a current winget install produces:
The fix.
WindowsRegistryReaderis split into a thin walker plus a pure staticWingetAspireEntryMatcher. The matcher reads the registry value at wire nameTargetFullPath; falls back toInstallLocation-containment whenTargetFullPathis absent (the zip+portable case); gates onWinGetInstallerType == "portable"permissively (tolerates null/empty for older winget builds, rejects"msi"/"exe"/etc.); and short-circuits onWinGetPackageIdentifierfirst inside the walk loop —HKCU\...\Uninstallhas hundreds of subkeys on a typical machine and this probe runs on every CLI invocation without a sidecar.InstallLocationcontainment uses case-insensitive Ordinal compare with a trailing-separator-aware boundary soC:\Foodoesn't matchC:\FooBar\aspire.exe.WingetFirstRunProbetakes a symlink-resolved real process path from both call sites (resolved viaCliPathHelper.ResolveSymlinkOrOriginalPath). The sidecar stamps next to the real binary, not next to the alias link.InstallationInfoOutput.DescribeSelfSafelynow takes and invokes the probe before reading the sidecar, mirroringDiscoverAllSafelyAsync.aspire doctor --selfprimes its own state.The probe's idempotency guard now consults
InstallSidecarReader.ReadSourceFieldinstead ofFile.Exists: it skips only when the sidecar exists AND itssourcefield parses cleanly. Malformed/oversized/empty-object sidecars fall through to the re-stamp path; a parseable foreign-route sidecar ({"source":"script"},{"source":"brew"}, etc.) is preserved verbatim. The atomic rename gains anoverwriteIfCorruptflag so cold-start writes still useoverwrite: false(concurrent-writer safety) while self-heal writes useoverwrite: true.A new
eng/scripts/verify-winget-install-detection.ps1is wired intoprepare-installer-artifacts.ymlto assert end-to-end on the freshly-installed CLI:aspire doctor --format jsonreportsinstallations[].route == "winget"..aspire-install.jsonsidecar exists next to the binary withsource=winget.~/.aspire/.~/.aspire/bundledoes NOT exist (catches regressions that populate both locations and would otherwise pass on positive-existence assertions alone).The script primes the probe and bundle extraction itself by running
aspire doctorbefore its assertions, so no separate CLI invocation by the workflow is needed. It runs with-ExpectedVersionparsed from the staged manifest'sPackageVersion, so a self-hosted runner with a pre-existing machine-wideaspireon PATH can't silently pass with the wrong binary.Test layering. Four levels guard against winget's wire format and the chain itself drifting:
WingetAspireEntryMatcherTests— 15 cross-platform unit cases over both portable shapes, separator boundary, case sensitivity, null/empty inputs, and theTargetFullPath-stale →InstallLocation-fallback path. Wire-name agnostic (callsMatches(...)with strings directly).WindowsRegistryReaderIntegrationTests— 6 Windows-only cases write synthetic ARP entries to HKCU under unique GUID-suffixed subkeys and run the real reader. Verified to catch a"TargetFullPath"→"PortableTargetFullPath"regression and an"InstallLocation"rename regression when the constants are flipped.WinGetRegistryShapeTests— 1 Windows-only case builds a synthetic zip+portable manifest, serves it over a loopbackHttpListener(winget'sWinINetrejectsfile://), runs realwinget install/uninstall, and asserts on the actual ARP values winget writes. The only layer that catches winget changing its wire format.WingetRegistryProbeTests— probe-level guards:Run_ForwardsRealProcessPathToRegistryReader(recording fake reader; fails if the probe re-introduces an internalEnvironment.ProcessPathread),Run_WritesSidecarNextToRealProcessPath_NotElsewhere,DescribeSelfSafely_RunsWingetFirstRunProbe,Run_OverwritesMalformedSidecar_WhenRegistryClaimsAspire(Theory over raw garbage, empty object, truncated JSON),Run_DoesNotOverwriteForeignSidecar_EvenWhenRegistryClaimsAspire.The Aspire manifest shape the matcher depends on (
InstallerType: zip+NestedInstallerType: portable+NestedInstallerFiles: aspire.exe) is asserted statically inPRScriptInstallerModeTests, so a manifest-template change that switches install shape is caught at inner-loop test time.Call-outs.
Write-Host -ForegroundColor Redrather thanWrite-Error: under$ErrorActionPreference='Stop',Write-Erroris terminating, so the first failed iteration would have aborted the loop and lost the diagnostic dump beforeexit 1.WindowsRegistryReaderIntegrationTestsreaps orphanMicrosoft.Aspire_AspireCliTests_*subkeys fromHKCU\...\Uninstallon first use, so a crashed prior run does not leave "Aspire CLI (... test) 0.0.0" entries in Settings → Apps on developer machines.tests/Aspire.Cli.Tests/Acquisition/cleanup-test-arp-entries.ps1lets devs purge a polluted machine by hand.TestUninstallEntry.CreateusesCreateSubKeyrather thanOpenSubKeyso a fresh user profile whereHKCU\...\Uninstalldoesn't yet exist (e.g. a clean GH Actions runner) doesn't fail with a misleading "not writable" error.WinGetRegistryShapeTestsprobes elevation up-front viaWindowsPrincipal. When the test isn't running elevated andLocalManifestFilesisn't already on, it skips with an actionable message instead of failing later with the cryptic0x8a150004 "Opening manifest failed". Loud-fail on elevated CI runners is preserved. The synthetic singleton manifest carriesPackageLocale: en-USbecause the singleton schema's top-levelrequiredarray lists it — easy to forget on a hand-rolled singleton, and missing it produces the same0x8a150004. The finally-block uninstall passes--accept-source-agreements --disable-interactivityso cleanup doesn't trip on the msstore source-agreement prompt.Out of scope: the winget manifest could set
ArchiveBinariesDependOnPath: trueto nudge winget toward an alias/symlink layout, which would make the path more obvious — separate change. Cross-route install/update/uninstall integration tests (mixing winget + script + dotnet-tool) are tracked in #17916.winget uninstallfailing on dogfoodLocalManifestFilesinstalls is tracked in #17944.Verified end-to-end on Windows against a real
winget install Microsoft.Aspire: before the fixinstallations[0]has noroutefield and the bundle extracts to~/.aspire/bundle; after the fixinstallations[0].route == "winget", the{"source":"winget"}sidecar is stamped next to the wingetaspire.exe(both when the CLI is launched directly and through theWinGet\Linksalias), bundle extraction colocates with the binary, and corrupting the sidecar to garbage self-heals on the next invocation. ThePrepare WinGet manifestsCI job exercises the full chain onwindows-latestevery PR.Fixes #17909.