Skip to content

fix(cli): detect winget install of aspire.exe so bundle stays in the winget package dir#17919

Draft
radical wants to merge 6 commits into
microsoft:mainfrom
radical:ankj/fix-winget-install-detection
Draft

fix(cli): detect winget install of aspire.exe so bundle stays in the winget package dir#17919
radical wants to merge 6 commits into
microsoft:mainfrom
radical:ankj/fix-winget-install-detection

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented Jun 4, 2026

Symptom. winget install Microsoft.Aspire produces an install that doesn't recognise itself as a winget install. aspire doctor --format json reports installations[0] without a route field; the install bundle extracts to ~/.aspire/bundle instead 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:

  1. WindowsRegistryReader looked up the registry value "PortableTargetFullPath", but winget writes that value under the wire name "TargetFullPath". The C++ identifier PortableTargetFullPath in winget-cli/src/AppInstallerCommonCore/PortableARPEntry.cpp maps to that wire string.

  2. Even with the correct wire name, the Aspire manifest (InstallerType: zip + NestedInstallerType: portable + multiple files in the archive) hits winget's PortableInstaller::InstallFile !RecordToIndex branch, which skips the per-file TargetFullPath ARP write entirely. The only path evidence in ARP is the InstallLocation directory.

  3. The first-run probe re-read Environment.ProcessPath internally, which on Windows returns the winget command-alias symlink path under %LOCALAPPDATA%\Microsoft\WinGet\Links\aspire.exe rather than the resolved binary under WinGet\Packages\. The matcher's InstallLocation containment check then missed.

  4. aspire doctor --format json --self skipped the probe entirely. DescribeSelfSafely didn't take the probe and went straight to discovery.DescribeSelf(), which reads a sidecar the probe never had a chance to stamp.

  5. 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 the source field) wedged route detection permanently — every subsequent aspire invocation read the sidecar as Invalid and fell back to the sidecar-less default extract dir.

The live ARP shape a current winget install produces:

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.

WindowsRegistryReader is split into a thin walker plus a pure static WingetAspireEntryMatcher. The matcher reads the registry value at wire name TargetFullPath; falls back to InstallLocation-containment when TargetFullPath is absent (the zip+portable case); gates on WinGetInstallerType == "portable" permissively (tolerates null/empty for older winget builds, rejects "msi"/"exe"/etc.); and short-circuits on WinGetPackageIdentifier first inside the walk loop — HKCU\...\Uninstall has hundreds of subkeys on a typical machine and this probe runs on every CLI invocation without a sidecar. InstallLocation containment uses case-insensitive Ordinal compare with a trailing-separator-aware boundary so C:\Foo doesn't match C:\FooBar\aspire.exe.

WingetFirstRunProbe takes a symlink-resolved real process path from both call sites (resolved via CliPathHelper.ResolveSymlinkOrOriginalPath). The sidecar stamps next to the real binary, not next to the alias link.

InstallationInfoOutput.DescribeSelfSafely now takes and invokes the probe before reading the sidecar, mirroring DiscoverAllSafelyAsync. aspire doctor --self primes its own state.

The probe's idempotency guard now consults InstallSidecarReader.ReadSourceField instead of File.Exists: it skips only when the sidecar exists AND its source field 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 an overwriteIfCorrupt flag so cold-start writes still use overwrite: false (concurrent-writer safety) while self-heal writes use overwrite: true.

A new eng/scripts/verify-winget-install-detection.ps1 is wired into prepare-installer-artifacts.yml to assert end-to-end on the freshly-installed CLI:

  1. aspire doctor --format json reports installations[].route == "winget".
  2. The .aspire-install.json sidecar exists next to the binary with source=winget.
  3. The bundle directory lives under the winget install location, not ~/.aspire/.
  4. The fallback ~/.aspire/bundle does 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 doctor before its assertions, so no separate CLI invocation by the workflow is needed. It runs with -ExpectedVersion parsed from the staged manifest's PackageVersion, so a self-hosted runner with a pre-existing machine-wide aspire on 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 the TargetFullPath-stale → InstallLocation-fallback path. Wire-name agnostic (calls Matches(...) 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 loopback HttpListener (winget's WinINet rejects file://), runs real winget 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 internal Environment.ProcessPath read), 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 in PRScriptInstallerModeTests, so a manifest-template change that switches install shape is caught at inner-loop test time.

Call-outs.

  • 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.
  • 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. TestUninstallEntry.Create uses CreateSubKey rather than OpenSubKey so a fresh user profile where HKCU\...\Uninstall doesn't yet exist (e.g. a clean GH Actions runner) doesn't fail with a misleading "not writable" error.
  • WinGetRegistryShapeTests probes elevation up-front via WindowsPrincipal. When the test isn't running elevated and LocalManifestFiles isn't 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. The synthetic singleton manifest carries PackageLocale: en-US because the singleton schema's top-level required array lists it — easy to forget on a hand-rolled singleton, and missing it produces the same 0x8a150004. The finally-block uninstall passes --accept-source-agreements --disable-interactivity so cleanup doesn't trip on the msstore source-agreement prompt.

Out of scope: the winget manifest could set ArchiveBinariesDependOnPath: true to 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 uninstall failing on dogfood LocalManifestFiles installs is tracked in #17944.

Verified end-to-end on Windows against a real winget install Microsoft.Aspire: before the fix installations[0] has no route field and the bundle extracts to ~/.aspire/bundle; after the fix installations[0].route == "winget", the {"source":"winget"} sidecar is stamped next to the winget aspire.exe (both when the CLI is launched directly and through the WinGet\Links alias), bundle extraction colocates with the binary, and corrupting the sidecar to garbage self-heals on the next invocation. The Prepare WinGet manifests CI job exercises the full chain on windows-latest every PR.

Fixes #17909.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 4, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17919

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17919"

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 4, 2026

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.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 4, 2026

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.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

@radical radical force-pushed the ankj/fix-winget-install-detection branch from 779fea5 to e0c6790 Compare June 4, 2026 23:44
…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>
@radical radical force-pushed the ankj/fix-winget-install-detection branch from e0c6790 to c91498f Compare June 4, 2026 23:58
radical and others added 2 commits June 4, 2026 23:29
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>
@radical
Copy link
Copy Markdown
Member Author

radical commented Jun 5, 2026

PR #17919 — Windows runbook results

Host: Windows 11, winget v1.29.170-preview, pwsh 7.6.1, elevated.
PR head exercised: 710e1bcc543c96de615ae13f2b8ac79aa33e94f4

Step EXPECT Result Notes
1 — get-aspire-cli-pr.ps1 -InstallMode WinGet winget install exit 0 Required two runs: my host had ~/.aspire/bundle left over from prior local dev (assertion 4 of step 3 demands its absence). After winget uninstall + manual cleanup of the package dir / ARP entry / Links symlink + Remove-Item ~/.aspire, the second install ran clean.
2 — version matches PR head version contains 710e1bcc 13.5.0-pr.17919.g710e1bcc. The runbook's hardcoded path Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe is wrong for dogfood installs — LocalManifestFiles makes the source suffix __DefaultSource, so the binary lives at …\Packages\Microsoft.Aspire__DefaultSource\aspire.exe. Steps 2/4/5 all need that path adjustment.
3 — verify script (4 assertions) all pass ✅ (after bundle extraction triggered manually) First attempt FAILED on 3/4 assertions because the install verifier (aspire --version from dogfood) does not trigger bundle extraction. Had to run aspire init --language csharp --suppress-agent-init --non-interactive followed by aspire restore --non-interactive in a scratch dir to actually populate the bundle. After that, all four assertions pass and the bundle is colocated under the winget package dir with no fallback.
4 — alias-path symlink resolution sidecar at real binary, not Links After wiping the sidecar and invoking via …\WinGet\Links\aspire.exe doctor --format json, the sidecar reappeared at …\Packages\Microsoft.Aspire__DefaultSource\.aspire-install.json with source=winget. Nothing was written under WinGet\Links\. The symlink-resolution fix this PR encodes is live.
5 — corrupt sidecar no crash, JSON intact ✅ on crash-handling; ❌ on the runbook's "re-stamp" expectation aspire doctor --format json returns valid JSON, exit 0, no stack trace. But the runbook's final step assumes the probe will overwrite the corrupt sidecar on the next invocation — it does not. WingetFirstRunProbe.Run bails out on File.Exists(sidecarPath) (src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs:65), so the corrupt content survives unless the user explicitly Remove-Items it. Either the probe needs a "validate-and-overwrite-if-corrupt" branch, or the runbook should drop the re-stamp claim.
6 — stale ~/.aspire/bundle verify script fails loudly After re-creating ~/.aspire/bundle with a sentinel file, the verify script exits 1 with the expected "Fallback bundle directory must not exist after a winget install" message.

Issues surfaced by the runbook that look like real concerns (not just runbook bugs)

  1. First-invocation route race. On a truly fresh install (no sidecar yet), aspire doctor --format json --self reports installations[0] with no route field. The probe runs inside BundleService.GetBundleExtractDirForCurrentProcess (called via EnsureExtractedAsync), but InstallationDiscovery.DiscoverSelf reads the sidecar first, before the probe writes it. Second and later invocations report route: "winget" correctly. If the verify script is the user's first post-install CLI command, assertion 1 will FAIL on a working install. Reproduced cleanly here: ran aspire doctor --self immediately after install → route missing → ran it again → route present.

  2. ~/.aspire/bundle is must-not-exist but no command in the install path actually extracts the bundle. The dogfood verifier ends with aspire --version, which neither triggers EnsureExtractedAsync nor the first-run probe. So a user who only runs winget install + the verify script will see assertion 3 fail ("bundle dir under winget pkg does not exist") and assertion 4 pass (no fallback bundle) — i.e. the script reports a false regression. The verify script's docstring says "Run AFTER … at least one CLI command has been invoked", but aspire --version doesn't satisfy that. aspire restore (in an init'd project) does. Either the verify script should run a bundle-triggering command itself, or aspire --version (or aspire doctor) should call EnsureExtractedAsync.

  3. Cosmetic, but related to [main] Update dependencies from dotnet/arcade #1: when the sidecar isn't yet present, the --self row's route field is omitted from JSON. installations[].route only appears when set. That's consistent with WhenWritingNull but means consumers can't distinguish "probe hasn't run yet" from "this is a sidecar-less install". A null vs missing vs "unknown" decision would help.

Other observations

  • winget uninstall --id Microsoft.Aspire and dogfood.ps1 -Uninstall both fail with -1978335212 for the dogfood install (source = __DefaultSource, not a known source). Cleanup required manual Remove-Item on the package dir, the Links\aspire.exe symlink, and the HKCU\…\Uninstall\Microsoft.Aspire__DefaultSource registry key. This is a winget-side limitation for local-manifest installs, not a PR bug, but it makes the runbook's Step 7 misleading.

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

@radical
Copy link
Copy Markdown
Member Author

radical commented Jun 5, 2026

Follow-up: confirmed root cause + revised report

Per @radical's suggestion, retested with plain aspire doctor (not --self) on a fully clean reinstall. That fully changes the picture:

aspire doctor (plain) works end-to-end on the first invocation: runs the probe, writes the sidecar, extracts the bundle to the winget package dir, and the table shows Route: winget. The PR's fix is wired up correctly for the normal doctor path.

The bug is specific to aspire doctor --self:

Path Triggers probe? Extracts bundle? Source
aspire doctor (plain) ✅ (via discovery's probe call) DoctorCommand.cs:84InstallationInfoOutput.DiscoverAllSafelyAsyncRunWingetFirstRunProbe(...) at InstallationInfoOutput.cs:22
aspire doctor --self DoctorCommand.cs:72InstallationInfoOutput.DescribeSelfSafely(_installationDiscovery, _logger)never calls the probe
// 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 verify-winget-install-detection.ps1 invokes aspire doctor --format json --self. On a clean install where that's the user's first CLI command after winget install, the verify script fails assertion 1 (route null) and — because the same probe-skip means EnsureExtractedAsync is never called either — also fails assertions 3 and 4 (bundle never extracted to pkg dir; ~/.aspire/bundle stays absent, which is technically correct, but the bundle just isn't anywhere). So the PR's regression-guard script doesn't actually guard against a clean-install regression today; it only passes once some other command has primed the state.

Suggested fix (one place): have DescribeSelfSafely take the probe and run it before discovery.DescribeSelf(), mirroring DiscoverAllSafelyAsync. Then update the two --self callsites in DoctorCommand. That makes aspire doctor --self self-contained on a clean install and lets the verify script actually exercise the PR's fix from a cold start.

Revised verdict on the runbook results

With this understood, my earlier "all green" claim about Step 3 was misleading — it passed only because I had previously run aspire restore which triggered extraction. On a truly clean install with the verify script as the first CLI command, Step 3 fails. Repro:

# 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 pass

Other findings still standing (lower priority)

  • route is omitted (not null) from JSON when missing. Consumers can't distinguish "probe hasn't run" from "sidecar-less install" from "winget but probe failed". A route: null (or explicit "unknown") would help downstream tooling.
  • WingetFirstRunProbe.Run refuses to overwrite an existing sidecar (File.Exists check at WingetFirstRunProbe.cs:65). A malformed sidecar persists across all subsequent invocations — route reads as null forever until manually deleted. Step 5 of the runbook implies the probe will re-stamp; it does not. Either probe should validate-and-overwrite-if-invalid, or the runbook should drop that claim. Low priority because the corrupt-sidecar scenario is unlikely in practice, but it does mean the system has no self-healing.
  • Runbook hardcodes Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe but dogfood installs land at Microsoft.Aspire__DefaultSource (LocalManifestFiles → __DefaultSource source-name suffix). Steps 2/4/5 path commands need adjustment.
  • winget uninstall and dogfood.ps1 -Uninstall both fail with -1978335212 for local-manifest installs. Runbook Step 7 cleanup is misleading; manual Remove-Item on pkg dir + Links symlink + HKCU\…\Uninstall\Microsoft.Aspire__DefaultSource is required.

radical and others added 2 commits June 5, 2026 02:05
`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>
@radical
Copy link
Copy Markdown
Member Author

radical commented Jun 5, 2026

✅ Windows live verification — green

Ran a before/after matrix on a real winget install Microsoft.Aspire on a Windows box, against commit 62c6d64da (HEAD). The point was to verify the two most recent commits in this PR — d6b0affbb (run probe in doctor --self) and 62c6d64da (self-heal corrupt sidecar) — under live winget state, since those two had only unit-test coverage in this PR before.

Setup. Single physical Microsoft.Aspire__DefaultSource package in %LOCALAPPDATA%\Microsoft\WinGet\Packages\. Pre-fix aspire.exe is the one winget originally installed at PR commit 710e1bcc (before fixes (4) and (5)). HEAD-built aspire.exe was staged whole-directory into a built\ subdir under the same InstallLocation. Same registry ARP entry in both rows of the table — only the binary changed.

Case Setup Pre-fix (710e1bcc) Post-fix (62c6d64da)
A Probe runs on --self sidecar deleted route: blank ❌ route: winget
B Self-heal corrupt sidecar = garbage route: blank ❌; garbage left in place route: winget ✓; sidecar rewritten to {"source":"winget"}
C Foreign route preserved sidecar = {"source":"script"} (same as post-fix by construction — old guard was File.Exists) sidecar untouched ✓; route: script
D Well-formed idempotent sidecar = {"source":"winget","custom":"hello"} (same) sidecar untouched ✓ (no rewrite churn)

Also ran:

  • All non-outerloop unit tests for Aspire.Cli.Tests (143 pass, 5 Unix-only skips) and Aspire.Acquisition.Tests (182 pass) on Windows
  • eng/scripts/verify-winget-install-detection.ps1 against the live install — exit 0, all four assertions pass, including the new Invoke-DoctorPriming step

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
Branch. pr-17919 at commit 62c6d64dacd4e81f1fb33e5938954de939823915 (fix(cli): self-heal corrupt winget install sidecar).
Machine. Windows, elevated PowerShell, winget on PATH, with a real winget install Microsoft.Aspire already present at version 13.5.0-pr.17919.g710e1bcc (i.e. the pre-fix PR head).

Companion docs (untracked in repo root):

  • pr-17919-followups.md — code-level followups from the windows runbook
  • pr-17919-winget-lifecycle-tests-handoff.md — proposed xunit lifecycle test class
  • pr-17919-windows-test-runbook.md — original manual runbook

This doc reports what was actually run and observed during this session — nothing was deferred.


TL;DR

All 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.

Layer What was verified Result
Unit tests 320 tests across 3 projects covering the probe, matcher, registry reader, doctor, bundle, and acquisition surface 320 pass, 5 Unix-only skips
End-to-end (live winget) Four-case matrix (sidecar missing / corrupt / foreign-route / well-formed) run against pre-fix binary then HEAD binary Bugs reproduce on pre-fix, all four pass on HEAD
Verify script eng/scripts/verify-winget-install-detection.ps1 against the live install Exit 0, all four assertions pass

What changed in this PR (recap)

Five commits between c91498fd7 and 62c6d64da, all on pr-17919:

  1. c91498fd7 — fix(cli): detect winget install of aspire.exe so bundle stays in the winget package dir (the main fix)
  2. cde0ba122 — fix(test): add PackageLocale to synthetic winget manifest
  3. 710e1bcc5 — fix(test): create HKCU...\Uninstall on demand for registry integration tests
  4. d6b0affbb — fix(cli): run winget first-run probe in doctor --self path
  5. 62c6d64da — fix(cli): self-heal corrupt winget install sidecar

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 windows-latest per the handoff doc.


Unit-test results

All commands run with the local .\.dotnet\dotnet.exe SDK (10.0.201). Filters per AGENTS.md: --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true", with a --hangdump --hangdump-timeout for safety.

Aspire.Cli.Tests — probe + matcher (the two-fix surface)

dotnet test --project tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj `
  --no-launch-profile -- `
  --filter-class "*.WingetRegistryProbeTests" `
  --filter-class "*.WingetAspireEntryMatcherTests" `
  --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"
Test run summary: Passed!
  total: 28
  failed: 0
  succeeded: 28
  skipped: 0
  duration: 1s 968ms

These are the canonical regression guards for the two fixes:

  • WingetRegistryProbeTests.DescribeSelfSafely_RunsWingetFirstRunProbe — locks down (4) via a recording fake registry reader; fails if DescribeSelfSafely ever skips the probe again.
  • WingetRegistryProbeTests.Run_OverwritesMalformedSidecar_WhenRegistryClaimsAspire (Theory: raw garbage, empty object, truncated JSON) — locks down (5)'s self-heal write.
  • WingetRegistryProbeTests.Run_DoesNotOverwriteForeignSidecar_EvenWhenRegistryClaimsAspire — locks down (5)'s "don't be over-eager" contract: a {"source":"script"} sidecar must survive untouched.

Aspire.Cli.Tests — Windows registry integration

--filter-class "*.WindowsRegistryReaderIntegrationTests"
total: 6
failed: 0
succeeded: 6
skipped: 0

Exercises the real Windows registry reader against synthetic ARP entries written to HKCU\...\Uninstall under unique GUID-suffixed subkeys. Confirms that the CreateSubKey(writable: true) fix from 710e1bcc5 works on this dev profile (the original failure mode was on a fresh runneradmin GHA profile where the parent key didn't yet exist).

Aspire.Cli.Tests — doctor + bundle + discovery

--filter-class "*.DoctorCommandTests" `
--filter-class "*.BundleServiceTests" `
--filter-class "*.InstallationDiscoveryDiscoverAllTests" `
--filter-class "*.PeerInstallProbeTests"
total: 109
failed: 0
succeeded: 104
skipped: 5
duration: 5s 808ms

The 5 skips are all Unix/macOS-only (POSIX $$, chmod for unreadable directories, macOS firmlink dedup). Expected and benign on Windows.

Aspire.Acquisition.Tests — full non-outerloop sweep

dotnet test --project tests\Aspire.Acquisition.Tests\Aspire.Acquisition.Tests.csproj `
  --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"
total: 182
failed: 0
succeeded: 182
skipped: 0
duration: 58s 475ms

Includes PRScriptInstallerModeTests and the static PowerShell_PrepareWinGetManifest_GenerateOnly_GeneratesManifestsWithArchiveHashesAndDogfood assertion that locks the manifest layout shape (InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: aspire.exe).


End-to-end live verification — before/after on the same machine

The high-signal result. On this box winget install Microsoft.Aspire had previously installed the binary built at PR commit 710e1bcc (the oldest commit on the branch, before fixes (4) and (5)). I ran the same four-case test matrix against:

  • (a) the live pre-fix aspire.exe at C:\Users\ankj\AppData\Local\Microsoft\WinGet\Packages\Microsoft.Aspire__DefaultSource\aspire.exe
  • (b) the HEAD-built aspire.exe from artifacts\bin\Aspire.Cli\Debug\net10.0\ staged into …\Microsoft.Aspire__DefaultSource\built\ so the InstallLocation-containment match still fires

Every case is a single invocation: <binary> doctor --format json --self. No priming command between cases; the sidecar state and the call itself are the entire input.

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

  1. The pre-fix binary is the one winget put on disk — discovered via Resolve-AspireBinary (follows the WinGet\Links\aspire.exe symlink) and matches version 13.5.0-pr.17919.g710e1bcc from aspire --version.
  2. The HEAD-built binary is artifacts\bin\Aspire.Cli\Debug\net10.0\aspire.exe, the framework-dependent net10.0 layout produced by build.cmd. Staged whole-directory-copy into …\built\ because it's framework-dependent and needs Aspire.Cli.dll etc. alongside.
  3. The InstallLocation registry value points at the parent dir; the matcher's containment check (added in c91498fd7) treats …\built\aspire.exe as contained, so the probe fires and writes the sidecar at …\built\.aspire-install.json.
  4. 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.json exists at the package dir with source=winget
  • ExpectedBundleDir (under the winget package dir) exists
  • FallbackBundleDir (~/.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 on windows-latest per pr-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 another aspiretest-winget-shape-* alias on the machine that needs the __DefaultSource manual 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.ps1 script 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.Aspire winget install at 13.5.0-pr.17919.g710e1bcc — unchanged from pre-session.
  • .aspire-install.json sidecar 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.exe alias under WinGet\Links\ — left in place; unrelated to this PR, it's leftover from a prior WinGetRegistryShapeTests run and is tracked under the __DefaultSource cleanup 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>
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.

Winget based install self-extracts to the wrong directory

1 participant