microsoft / microsoft/aspire

Polyglot (guest) AppHost restore does not isolate staging NuGet packages from global cache

Open
#15,399 2 comments 0 reactions 0 assignees View on GitHub
area-cli
Dominant language
C#
Stars
6.3k
Forks
991
Avg merge
2d 15h
Merged PRs (30d)
196

Description

## Summary

When the Aspire CLI performs an `aspire restore` for a **polyglot (guest/non-.NET) AppHost** (e.g. TypeScript) with the channel set to `staging`, the restored NuGet packages are not isolated from the default global NuGet cache. This differs from the behavior of non-polyglot (.NET) AppHost restores, where staging packages are properly isolated in a local `.nugetpackages` folder.

This causes **stale package reuse** when upgrading between different staging builds that share the same version number (e.g. two different builds both versioned `13.2.0` but with different commit hashes).

## Root Cause

There are two separate restore paths for staging:

### Non-polyglot (.NET AppHost) path — ✅ Works correctly

`PackagingService.CreateStagingChannel()` creates the staging channel with `configureGlobalPackagesFolder: !useSharedFeed` ([PackagingService.cs L96-100](https://github.com/dotnet/aspire/blob/main/src/Aspire.Cli/Packaging/PackagingService.cs)):

```csharp
var stagingChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Staging, stagingQuality, new[]
{
new PackageMapping("Aspire*", stagingFeedUrl),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, ...);
```

For stable-quality staging, `configureGlobalPackagesFolder` is `true`. This causes `NuGetConfigMerger` to inject a `globalPackagesFolder=".nugetpackages"` entry into the temporary NuGet config ([NuGetConfigMerger.cs L963-984](https://github.com/dotnet/aspire/blob/main/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs)), isolating staging packages from the global cache.

### Polyglot (guest AppHost) path — ❌ Missing isolation

`PrebuiltAppHostServer.RestoreNuGetPackagesAsync()` ([PrebuiltAppHostServer.cs L164-179](https://github.com/dotnet/aspire/blob/main/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs)) only extracts **feed URLs** from the channel and passes them as `--source` arguments to `aspire-managed nuget restore`:

```csharp
private async Task RestoreNuGetPackagesAsync(...)
{
var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList();
var sources = await GetNuGetSourcesAsync(channelName, cancellationToken);

return await _nugetService.RestorePackagesAsync(
packages, ..., sources: sources, ...);
}
```

The `BundleNuGetService` invokes `aspire-managed nuget restore` ([BundleNuGetService.cs L91-136](https://github.com/dotnet/aspire/blob/main/src/Aspire.Cli/NuGet/BundleNuGetService.cs)), which uses the **default NuGet global packages folder** (`~/.nuget/packages/`). The `configureGlobalPackagesFolder` isolation from the channel is never applied.

Additionally, `aspire-managed` is a self-contained executable that does not read the host system's NuGet configuration. This means even a custom `globalPackagesFolder` configured via `nuget.config` or environment variables for the system `dotnet` CLI will not be respected by `aspire-managed`. Running `dotnet nuget locals all --clear` clears the system-configured cache location but **not** the default `~/.nuget/packages/` that `aspire-managed` uses.

## Reproduction

1. Install a staging build of the Aspire CLI (e.g., `13.2.0+`)
2. Create a TypeScript AppHost with `aspire.config.json` containing `"channel": "staging"` and packages at version `13.2.0`
3. Run `aspire restore` — packages from `commitA` are cached in `~/.nuget/packages/aspire.hosting/13.2.0/`
4. Update the CLI to a newer staging build (`13.2.0+`) which has a different `darc-pub-dotnet-aspire-*` feed
5. Clear the Aspire cache (`~/.aspire/packages/`) and run `aspire restore` again
6. **Expected:** Packages from `commitB`'s feed are restored
7. **Actual:** Packages from `commitA` are still used because `~/.nuget/packages/aspire.hosting/13.2.0/` already exists and NuGet skips the download

## Additional compounding factor

The `BundleNuGetService` also has its own caching layer at `~/.aspire/packages/restore/{hash}/libs/` where the hash is computed from **package names + versions + TFM** only ([BundleNuGetService.cs L195-203](https://github.com/dotnet/aspire/blob/main/src/Aspire.Cli/NuGet/BundleNuGetService.cs)). Since two different staging builds share the same version string, the hash is identical, meaning this cache layer also collides.

## Suggested fix

Consider one or more of:

1. **Apply `globalPackagesFolder` isolation** in the `BundleNuGetService` path for staging, similar to the .NET AppHost path (e.g., pass a `--packages-dir` argument to `aspire-managed nuget restore` pointing to a local or staging-specific folder)
2. **Include the feed URL (or CLI commit hash) in the `BundleNuGetService` cache hash** so different staging builds don't collide on the same version string
3. **Pass the `--no-cache` or `--force` flag** to `aspire-managed nuget restore` when the channel is staging

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.