From 0a22ef96c46a363c83fb1556db7a62f3860d0a07 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 11:20:38 -0700 Subject: [PATCH 01/25] Fix PR channel version selection in CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 14 ++- src/Aspire.Cli/Commands/InitCommand.cs | 10 ++ src/Aspire.Cli/Commands/NewCommand.cs | 13 ++- .../Templating/DotNetTemplateFactory.cs | 10 ++ src/Aspire.Cli/Utils/VersionHelper.cs | 5 + .../Commands/AddCommandTests.cs | 76 +++++++++++++ .../Commands/InitCommandTests.cs | 103 ++++++++++++++++++ .../Commands/NewCommandTests.cs | 76 +++++++++++++ .../TestServices/TestPackagingService.cs | 7 ++ 9 files changed, 311 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index fc6f1db8282..9ddcb5af4bb 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -294,7 +294,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) }; - var packageVersions = possiblePackages.Where(p => p.Package.Id == selectedPackage.Package.Id); + var packageVersions = possiblePackages.Where(p => p.Package.Id == selectedPackage.Package.Id).ToArray(); // If any of the package versions are an exact match for the preferred version // then we can skip the version prompt and just use that version. @@ -304,6 +304,18 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => return preferredVersionPackage; } + // When PR hives are present, prefer the package that exactly matches the installed + // CLI/SDK version so template- and add-generated projects stay on the same build. + if (ExecutionContext.GetPrHiveCount() > 0) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var cliVersionPackage = packageVersions.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); + if (cliVersionPackage.Package is not null) + { + return cliVersionPackage; + } + } + // In non-interactive mode, prefer the implicit/default channel first to keep // package selection aligned with the project's configured feeds. Then select // the latest version within the chosen channel. diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index d0014a8f512..76952379be7 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -796,6 +796,16 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => } } + if (hasChannelSetting && VersionHelper.IsPrChannel(channelName)) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var cliVersionPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); + if (cliVersionPackageFromChannel.Package is not null) + { + return cliVersionPackageFromChannel; + } + } + // If channel was specified via --channel option or global setting (but no --version), // automatically select the highest version from that channel without prompting if (hasChannelSetting) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index e0987fb5610..db3452c0c28 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -318,9 +318,18 @@ private async Task ResolveCliTemplateVersionAsync( return new ResolveTemplateVersionResult { ErrorMessage = errorMessage }; } - var packages = await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken); - var package = packages + var packages = (await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken)) .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) + .ToArray(); + + NuGetPackage? package = null; + if (VersionHelper.IsPrChannel(selectedChannel.Name)) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + package = packages.FirstOrDefault(p => string.Equals(p.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); + } + + package ??= packages .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) .FirstOrDefault(); diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 135a7d714a2..ec0fc40ca3c 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -710,6 +710,16 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => } } + if (hasChannelSetting && VersionHelper.IsPrChannel(channelName)) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var cliVersionPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); + if (cliVersionPackageFromChannel.Package is not null) + { + return cliVersionPackageFromChannel; + } + } + // If channel was specified via --channel option or global setting (but no --version), // automatically select the highest version from that channel without prompting if (hasChannelSetting) diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index fb5cef67df8..422cef491df 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -8,6 +8,11 @@ namespace Aspire.Cli.Utils; internal static class VersionHelper { + public static bool IsPrChannel(string? channelName) + { + return channelName?.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) == true; + } + public static string GetDefaultTemplateVersion() { return PackageUpdateHelpers.GetCurrentAssemblyVersion() ?? throw new InvalidOperationException(ErrorStrings.UnableToRetrieveAssemblyVersion); diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 6b7921dac29..56d0ae343e3 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using NuGetPackage = Aspire.Shared.NuGetPackageCli; using Microsoft.AspNetCore.InternalTesting; @@ -812,6 +813,81 @@ public async Task AddCommand_WithHives_PrefersImplicitChannelVersionInNonInterac Assert.Equal(0, exitCode); Assert.Equal("13.2.0-pr.12345.gabc", selectedPackageVersion); } + + [Fact] + public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + hivesDir.Create(); + hivesDir.CreateSubdirectory("pr-12345"); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var selectedPackageVersion = string.Empty; + var promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt when the current CLI version is available in a PR hive."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + var implicitPackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "implicit", + Version = "13.2.2" + }; + + var prHivePackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "pr-hive", + Version = cliVersion + }; + + return nugetSource is null + ? (0, new[] { implicitPackage }) + : (0, new[] { prHivePackage }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + selectedPackageVersion = packageVersion; + return 0; + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add redis"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(promptedForVersion); + Assert.Equal(cliVersion, selectedPackageVersion); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 53998e37f4f..7ab7069a486 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -524,6 +525,84 @@ public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() Assert.False(promptedForVersion); } + [Fact] + public async Task InitCommandWithPrChannelPrefersCurrentCliVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + string? selectedVersion = null; + bool promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.NewCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestNewCommandPrompter(interactionService); + + prompter.PromptForTemplatesVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt for version when a PR channel contains the current CLI version."); + }; + + return prompter; + }; + + options.PackagingServiceFactory = (sp) => + { + return new Aspire.Cli.Tests.TestServices.TestPackagingService + { + GetChannelsAsyncCallback = (ct) => + { + var fakeCache = new CallbackNuGetPackageCache( + (dir, prerelease, nugetConfig, ct) => + { + var packages = new[] + { + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = cliVersion }, + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0" }, + }; + + return Task.FromResult>(packages); + }); + + var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache); + return Task.FromResult>([prChannel]); + } + }; + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + { + selectedVersion = version; + return (0, version); + }; + runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) => + { + var appHostFile = Path.Combine(outputPath, "apphost.cs"); + File.WriteAllText(appHostFile, "// Test apphost file"); + return 0; + }; + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("init --channel pr-12345"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Equal(cliVersion, selectedVersion); + Assert.False(promptedForVersion); + } + [Fact] public async Task InitCommandWithInvalidChannelShowsError() { @@ -693,4 +772,28 @@ private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Actio return Task.FromResult>(Array.Empty()); } } + + private sealed class CallbackNuGetPackageCache( + Func>> getTemplatePackagesAsyncCallback) : INuGetPackageCache + { + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + return getTemplatePackagesAsyncCallback(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 87d9ac85111..79db8439dab 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -459,6 +459,82 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() Assert.False(promptedForVersion); // Should not prompt when --channel is specified } + [Fact] + public async Task NewCommandWithPrChannelPrefersCurrentCliVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + string? selectedVersion = null; + bool promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.NewCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestNewCommandPrompter(interactionService); + + prompter.PromptForTemplatesVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt for version when a PR channel contains the current CLI version."); + }; + + return prompter; + }; + + options.PackagingServiceFactory = (sp) => + { + var packagingService = new NewCommandTestPackagingService(); + packagingService.GetChannelsAsyncCallback = (ct) => + { + var fakeCache = new NewCommandTestFakeNuGetPackageCache(); + fakeCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => + { + var packages = new[] + { + new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = cliVersion }, + new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0" }, + }; + + return Task.FromResult>(packages); + }; + + var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache); + return Task.FromResult>([prChannel]); + }; + + return packagingService; + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + { + selectedVersion = version; + return (0, version); + }; + runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) => + { + return 0; + }; + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("new aspire-starter --channel pr-12345 --use-redis-cache --test-framework None"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Equal(cliVersion, selectedVersion); + Assert.False(promptedForVersion); + } + [Fact] // Quarantined due to flakiness. See linked issue for details. public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine() diff --git a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index 1f884093112..652adc1768c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -7,8 +7,15 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestPackagingService : IPackagingService { + public Func>>? GetChannelsAsyncCallback { get; init; } + public Task> GetChannelsAsync(CancellationToken cancellationToken = default) { + if (GetChannelsAsyncCallback is not null) + { + return GetChannelsAsyncCallback(cancellationToken); + } + return Task.FromResult(Enumerable.Empty()); } } From 39aff7d85400d93d51b74e1c9f19baef193e6f89 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 11:32:49 -0700 Subject: [PATCH 02/25] Deduplicate PR version selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 13 ++++---- src/Aspire.Cli/Commands/InitCommand.cs | 15 +++++---- src/Aspire.Cli/Commands/NewCommand.cs | 13 ++++---- .../Templating/DotNetTemplateFactory.cs | 15 +++++---- src/Aspire.Cli/Utils/VersionHelper.cs | 31 +++++++++++++++++++ 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 9ddcb5af4bb..6bed474fe7d 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -306,14 +306,13 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => // When PR hives are present, prefer the package that exactly matches the installed // CLI/SDK version so template- and add-generated projects stay on the same build. - if (ExecutionContext.GetPrHiveCount() > 0) + if (VersionHelper.TryGetCurrentCliVersionMatch( + packageVersions, + p => p.Package.Version, + out var cliVersionPackage, + hasPrHives: ExecutionContext.GetPrHiveCount() > 0)) { - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var cliVersionPackage = packageVersions.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); - if (cliVersionPackage.Package is not null) - { - return cliVersionPackage; - } + return cliVersionPackage; } // In non-interactive mode, prefer the implicit/default channel first to keep diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 76952379be7..a47e34e9107 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -796,17 +796,16 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => } } - if (hasChannelSetting && VersionHelper.IsPrChannel(channelName)) + if (VersionHelper.TryGetCurrentCliVersionMatch( + orderedPackagesFromChannels, + p => p.Package.Version, + out var cliVersionPackageFromChannel, + channelName: channelName)) { - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var cliVersionPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); - if (cliVersionPackageFromChannel.Package is not null) - { - return cliVersionPackageFromChannel; - } + return cliVersionPackageFromChannel; } - // If channel was specified via --channel option or global setting (but no --version), + // If channel was specified via --channel option or global setting (but no --version), // automatically select the highest version from that channel without prompting if (hasChannelSetting) { diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index db3452c0c28..aafc97c18cf 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -322,12 +322,13 @@ private async Task ResolveCliTemplateVersionAsync( .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) .ToArray(); - NuGetPackage? package = null; - if (VersionHelper.IsPrChannel(selectedChannel.Name)) - { - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - package = packages.FirstOrDefault(p => string.Equals(p.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); - } + NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch( + packages, + p => p.Version, + out var cliVersionPackage, + channelName: selectedChannel.Name) + ? cliVersionPackage + : null; package ??= packages .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index ec0fc40ca3c..36f7598682f 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -710,17 +710,16 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => } } - if (hasChannelSetting && VersionHelper.IsPrChannel(channelName)) + if (VersionHelper.TryGetCurrentCliVersionMatch( + orderedPackagesFromChannels, + p => p.Package.Version, + out var cliVersionPackageFromChannel, + channelName: channelName)) { - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var cliVersionPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => string.Equals(p.Package.Version, cliVersion, StringComparison.OrdinalIgnoreCase)); - if (cliVersionPackageFromChannel.Package is not null) - { - return cliVersionPackageFromChannel; - } + return cliVersionPackageFromChannel; } - // If channel was specified via --channel option or global setting (but no --version), + // If channel was specified via --channel option or global setting (but no --version), // automatically select the highest version from that channel without prompting if (hasChannelSetting) { diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 422cef491df..44289406a3c 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Cli.Resources; using Aspire.Shared; @@ -13,6 +14,36 @@ public static bool IsPrChannel(string? channelName) return channelName?.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) == true; } + public static bool TryGetCurrentCliVersionMatch( + IEnumerable candidates, + Func versionSelector, + [MaybeNullWhen(false)] out T match, + string? channelName = null, + bool hasPrHives = false) + { + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(versionSelector); + + if (!hasPrHives && !IsPrChannel(channelName)) + { + match = default; + return false; + } + + var cliVersion = GetDefaultSdkVersion(); + foreach (var candidate in candidates) + { + if (string.Equals(versionSelector(candidate), cliVersion, StringComparison.OrdinalIgnoreCase)) + { + match = candidate; + return true; + } + } + + match = default; + return false; + } + public static string GetDefaultTemplateVersion() { return PackageUpdateHelpers.GetCurrentAssemblyVersion() ?? throw new InvalidOperationException(ErrorStrings.UnableToRetrieveAssemblyVersion); From 61a46d4cf70fff1694b580969976191b5ebb3de2 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 11:43:06 -0700 Subject: [PATCH 03/25] Address CLI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/VersionHelper.cs | 3 +++ tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs | 13 ++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 44289406a3c..05c86bed0c7 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -14,6 +14,9 @@ public static bool IsPrChannel(string? channelName) return channelName?.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) == true; } + /// + /// Finds the candidate that exactly matches the current CLI/SDK version when running against PR channels or PR hives. + /// public static bool TryGetCurrentCliVersionMatch( IEnumerable candidates, Func versionSelector, diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 7ab7069a486..039362741d8 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -98,7 +98,7 @@ public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_Proceeds() }; options.PackagingServiceFactory = (sp) => { - return new TestPackagingService(); + return new ImplicitChannelPackagingService(); }; }); @@ -279,7 +279,7 @@ public async Task InitCommand_WhenNewProjectFails_SetsOutputCollectorAndCallsCal // Mock packaging service options.PackagingServiceFactory = (sp) => { - return new TestPackagingService(); + return new ImplicitChannelPackagingService(); }; }); @@ -375,7 +375,7 @@ public async Task InitCommand_WithSingleFileAppHost_DoesNotPromptForProjectNameO // Mock packaging service to return fake channels options.PackagingServiceFactory = (sp) => { - return new TestPackagingService(); + return new ImplicitChannelPackagingService(); }; }); @@ -427,8 +427,7 @@ public override Task PromptForOutputPath(string defaultPath, Cancellatio } } - // Test implementation of IPackagingService - private sealed class TestPackagingService : IPackagingService + private sealed class ImplicitChannelPackagingService : IPackagingService { public Task> GetChannelsAsync(CancellationToken cancellationToken = default) { @@ -552,7 +551,7 @@ public async Task InitCommandWithPrChannelPrefersCurrentCliVersion() options.PackagingServiceFactory = (sp) => { - return new Aspire.Cli.Tests.TestServices.TestPackagingService + return new TestPackagingService { GetChannelsAsyncCallback = (ct) => { @@ -660,7 +659,7 @@ public async Task InitCommand_WhenCSharpInitializationFails_DisplaysCreationErro }; options.PackagingServiceFactory = (sp) => { - return new TestPackagingService(); + return new ImplicitChannelPackagingService(); }; }); From f31eccc317a04b13bac23ad50d1b0817fd940f4e Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 12:19:40 -0700 Subject: [PATCH 04/25] Handle auto-selected add versions in k8s tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/KubernetesDeployTestHelpers.cs | 67 +++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index eb8a06ecd3d..bf82cabdce4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -272,10 +272,7 @@ await auto.WaitUntilAsync( { await auto.TypeAsync($"aspire add {package}"); await auto.EnterAsync(); - // aspire add shows a version selection prompt — accept the first (latest) version - await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.HandleAspireAddVersionSelectionAsync(counter); } // Step 4: Add client NuGet packages to ApiService (--prerelease needed for PR builds) @@ -394,6 +391,67 @@ internal static async Task VerifyDeploymentAsync( await auto.WaitForAnyPromptAsync(counter); } + private static async Task HandleAspireAddVersionSelectionAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter) + { + var versionPromptSearcher = new CellPatternSearcher().Find("(based on NuGet.config)"); + var successPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + var errorPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" ERR:"); + + var showedVersionPrompt = false; + var sawSuccessPrompt = false; + var sawErrorPrompt = false; + + await auto.WaitUntilAsync( + snapshot => + { + if (versionPromptSearcher.Search(snapshot).Count > 0) + { + showedVersionPrompt = true; + return true; + } + + if (successPromptSearcher.Search(snapshot).Count > 0) + { + sawSuccessPrompt = true; + return true; + } + + if (errorPromptSearcher.Search(snapshot).Count > 0) + { + sawErrorPrompt = true; + return true; + } + + return false; + }, + timeout: TimeSpan.FromSeconds(60), + description: $"aspire add version prompt or completion [{counter.Value} OK/ERR] $"); + + if (showedVersionPrompt) + { + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + return; + } + + if (sawErrorPrompt) + { + throw new InvalidOperationException( + $"aspire add exited with a non-zero code before prompting for version selection (sequence {counter.Value})."); + } + + if (sawSuccessPrompt) + { + counter.Increment(); + } + } + /// /// Cleans up a KinD cluster and registry (best-effort, in-terminal). /// @@ -451,4 +509,3 @@ internal static async Task CleanupKindClusterOutOfBandAsync(string clusterName, } } } - From 742357b89fbf7aeb486911ddda0f4264f2fcff0c Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 15:14:28 -0700 Subject: [PATCH 05/25] Fix CLI add package source forwarding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 8 ++- .../Commands/AddCommandTests.cs | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 6bed474fe7d..21d67d6e7c8 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -208,12 +208,18 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => }; // Add the package using the appropriate project handler + var addPackageSource = source; + if (string.IsNullOrEmpty(addPackageSource) && selectedNuGetPackage.Channel.Type is PackageChannelType.Explicit) + { + addPackageSource = selectedNuGetPackage.Channel.SourceDetails; + } + context = new AddPackageContext { AppHostFile = effectiveAppHostProjectFile, PackageId = selectedNuGetPackage.Package.Id, PackageVersion = selectedNuGetPackage.Package.Version, - Source = source + Source = addPackageSource }; // Stop any running AppHost instance before adding the package. diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 56d0ae343e3..3766ad331d6 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -888,6 +888,68 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); } + + [Fact] + public async Task AddCommand_WithPrHive_ForwardsSelectedChannelSourceToAddPackage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + hivesDir.Create(); + var prHiveDir = hivesDir.CreateSubdirectory("pr-12345"); + prHiveDir.CreateSubdirectory("packages"); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var expectedSource = Path.Combine(prHiveDir.FullName, "packages").Replace('\\', '/'); + string? addUsedSource = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + var implicitPackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "implicit", + Version = "13.2.2" + }; + + var prHivePackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "pr-hive", + Version = cliVersion + }; + + return nugetSource is null + ? (0, new[] { implicitPackage }) + : (0, new[] { prHivePackage }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + addUsedSource = nugetSource; + return 0; + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add redis"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Equal(expectedSource, addUsedSource); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) From b837e754429a15fb2b2765e76c87b5c6cb88915b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 13 Apr 2026 16:47:19 -0700 Subject: [PATCH 06/25] Fix CLI add PR hive restore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 17 ++++++++++++----- .../Commands/AddCommandTests.cs | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 21d67d6e7c8..5d4cc4b354c 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -208,10 +208,13 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => }; // Add the package using the appropriate project handler - var addPackageSource = source; - if (string.IsNullOrEmpty(addPackageSource) && selectedNuGetPackage.Channel.Type is PackageChannelType.Explicit) + if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) { - addPackageSource = selectedNuGetPackage.Channel.SourceDetails; + var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); + await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( + effectiveAppHostProjectFile.Directory!, + selectedNuGetPackage.Channel, + cancellationToken); } context = new AddPackageContext @@ -219,7 +222,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => AppHostFile = effectiveAppHostProjectFile, PackageId = selectedNuGetPackage.Package.Id, PackageVersion = selectedNuGetPackage.Package.Version, - Source = addPackageSource + Source = source }; // Stop any running AppHost instance before adding the package. @@ -312,8 +315,12 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => // When PR hives are present, prefer the package that exactly matches the installed // CLI/SDK version so template- and add-generated projects stay on the same build. + var prChannelPackageVersions = packageVersions + .Where(p => VersionHelper.IsPrChannel(p.Channel.Name)) + .ToArray(); + if (VersionHelper.TryGetCurrentCliVersionMatch( - packageVersions, + prChannelPackageVersions, p => p.Package.Version, out var cliVersionPackage, hasPrHives: ExecutionContext.GetPrHiveCount() > 0)) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 3766ad331d6..7ddffe004c5 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -890,7 +890,7 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() } [Fact] - public async Task AddCommand_WithPrHive_ForwardsSelectedChannelSourceToAddPackage() + public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleSource() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -902,10 +902,16 @@ public async Task AddCommand_WithPrHive_ForwardsSelectedChannelSourceToAddPackag var cliVersion = VersionHelper.GetDefaultSdkVersion(); var expectedSource = Path.Combine(prHiveDir.FullName, "packages").Replace('\\', '/'); string? addUsedSource = null; + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); + var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.ProjectLocatorFactory = _ => new TestProjectLocator(); + options.ProjectLocatorFactory = _ => new TestProjectLocator + { + UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(appHostFile) + }; options.DotNetCliRunnerFactory = (sp) => { @@ -948,7 +954,11 @@ public async Task AddCommand_WithPrHive_ForwardsSelectedChannelSourceToAddPackag var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); - Assert.Equal(expectedSource, addUsedSource); + Assert.Null(addUsedSource); + + var nuGetConfigPath = Path.Combine(appHostDirectory.FullName, "nuget.config"); + Assert.True(File.Exists(nuGetConfigPath)); + Assert.Contains(expectedSource, File.ReadAllText(nuGetConfigPath), StringComparison.Ordinal); } } From c46d315f55b1462ddd900dfbad737f1967570eca Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 14 Apr 2026 07:26:53 -0700 Subject: [PATCH 07/25] Fix PR hive add mapping and GA deploy wait Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 2 +- src/Aspire.Cli/Packaging/PackageChannel.cs | 100 +++++++++++++++++- .../Commands/AddCommandTests.cs | 5 +- .../AcaCompactNamingUpgradeDeploymentTests.cs | 3 +- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 5d4cc4b354c..521e07212b2 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -213,7 +213,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel, + selectedNuGetPackage.Channel.CreateScopedChannelForPackage(selectedNuGetPackage.Package.Id), cancellationToken); } diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 5bcd3bb2071..2ac6b9cd5d3 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; +using System.Xml.Linq; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; +using Aspire.Cli.Utils; using Semver; using NuGetPackage = Aspire.Shared.NuGetPackageCli; @@ -193,6 +196,101 @@ public async Task> GetPackagesAsync(string packageId, return filteredPackages; } + public PackageChannel CreateScopedChannelForPackage(string packageId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + + var mappings = Mappings; + if (!VersionHelper.IsPrChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 }) + { + return this; + } + + var scopedMappings = mappings + .SelectMany(mapping => CreateScopedMappings(mapping, packageId)) + .ToArray(); + + return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion); + } + + private static IEnumerable CreateScopedMappings(PackageMapping mapping, string packageId) + { + if (!IsScopedAspireMapping(mapping)) + { + yield return mapping; + yield break; + } + + var packageIds = GetScopedPackageIds(mapping.Source); + if (packageIds.Count == 0) + { + yield return new PackageMapping(packageId, mapping.Source); + yield break; + } + + foreach (var scopedPackageId in packageIds) + { + yield return new PackageMapping(scopedPackageId, mapping.Source); + } + } + + private static HashSet GetScopedPackageIds(string source) + { + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!Directory.Exists(source)) + { + return packageIds; + } + + foreach (var packageFile in Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly)) + { + if (TryGetPackageId(packageFile) is { Length: > 0 } packageId) + { + packageIds.Add(packageId); + } + } + + return packageIds; + } + + private static string? TryGetPackageId(string packageFile) + { + try + { + using var archive = ZipFile.OpenRead(packageFile); + var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + if (nuspecEntry is null) + { + return null; + } + + using var stream = nuspecEntry.Open(); + var document = XDocument.Load(stream); + var metadata = document.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "metadata"); + var id = metadata?.Elements().FirstOrDefault(e => e.Name.LocalName == "id")?.Value; + return string.IsNullOrWhiteSpace(id) ? null : id; + } + catch (IOException) + { + return null; + } + catch (InvalidDataException) + { + return null; + } + catch (System.Xml.XmlException) + { + return null; + } + } + + private static bool IsScopedAspireMapping(PackageMapping mapping) + { + return mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) && + !string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal); + } + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); @@ -207,4 +305,4 @@ public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPacka // for broader templating options. return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 7ddffe004c5..2c5e9f28c55 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -958,7 +958,10 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS var nuGetConfigPath = Path.Combine(appHostDirectory.FullName, "nuget.config"); Assert.True(File.Exists(nuGetConfigPath)); - Assert.Contains(expectedSource, File.ReadAllText(nuGetConfigPath), StringComparison.Ordinal); + var nuGetConfigContents = File.ReadAllText(nuGetConfigPath); + Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); + Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); + Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index b851b60a766..f9ced38bf18 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -239,8 +239,7 @@ await auto.WaitUntilAsync(s => output.WriteLine("Step 9: First deployment with GA CLI..."); await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(30)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(35)); // Step 10: Record the storage account count after first deploy output.WriteLine("Step 10: Recording storage account count after GA deploy..."); From 15ac30a24e3e550f9d736891a59966cf5ac4f062 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 14 Apr 2026 07:53:53 -0700 Subject: [PATCH 08/25] Require explicit PR hive matching inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 1 + src/Aspire.Cli/Commands/InitCommand.cs | 4 +- src/Aspire.Cli/Commands/NewCommand.cs | 4 +- .../Templating/DotNetTemplateFactory.cs | 7 +- src/Aspire.Cli/Utils/VersionHelper.cs | 4 +- .../Commands/InitCommandTests.cs | 95 +++++++++++++++++++ .../Utils/VersionHelperTests.cs | 30 ++++++ 7 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 521e07212b2..dc3b6a8fba6 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -323,6 +323,7 @@ await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( prChannelPackageVersions, p => p.Package.Version, out var cliVersionPackage, + channelName: null, hasPrHives: ExecutionContext.GetPrHiveCount() > 0)) { return cliVersionPackage; diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index a47e34e9107..e01b479a3f7 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -784,6 +784,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => throw new InvalidOperationException("No template versions found"); } + var hasPrHives = _executionContext.GetPrHiveCount() > 0; var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer); // Check for explicit version specified via command line @@ -800,7 +801,8 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => orderedPackagesFromChannels, p => p.Package.Version, out var cliVersionPackageFromChannel, - channelName: channelName)) + channelName: channelName, + hasPrHives: hasPrHives)) { return cliVersionPackageFromChannel; } diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index aafc97c18cf..5d0155511c3 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -321,12 +321,14 @@ private async Task ResolveCliTemplateVersionAsync( var packages = (await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken)) .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) .ToArray(); + var hasPrHives = ExecutionContext.GetPrHiveCount() > 0; NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch( packages, p => p.Version, out var cliVersionPackage, - channelName: selectedChannel.Name) + channelName: selectedChannel.Name, + hasPrHives: hasPrHives) ? cliVersionPackage : null; diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 36f7598682f..f96f6ecffad 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -654,6 +654,7 @@ private async Task GetOutputPathAsync(TemplateInputs inputs, Func channels; + var hasPrHives = executionContext.GetPrHiveCount() > 0; bool hasChannelSetting = !string.IsNullOrEmpty(channelName); if (hasChannelSetting) @@ -671,8 +672,7 @@ private async Task GetOutputPathAsync(TemplateInputs inputs, Func 0; - channels = hasHives + channels = hasPrHives ? allChannels : allChannels.Where(c => c.Type is PackageChannelType.Implicit); } @@ -714,7 +714,8 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => orderedPackagesFromChannels, p => p.Package.Version, out var cliVersionPackageFromChannel, - channelName: channelName)) + channelName: channelName, + hasPrHives: hasPrHives)) { return cliVersionPackageFromChannel; } diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 05c86bed0c7..53c764b37a2 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -21,8 +21,8 @@ public static bool TryGetCurrentCliVersionMatch( IEnumerable candidates, Func versionSelector, [MaybeNullWhen(false)] out T match, - string? channelName = null, - bool hasPrHives = false) + string? channelName, + bool hasPrHives) { ArgumentNullException.ThrowIfNull(candidates); ArgumentNullException.ThrowIfNull(versionSelector); diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 039362741d8..6b95b9b0512 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -602,6 +602,101 @@ public async Task InitCommandWithPrChannelPrefersCurrentCliVersion() Assert.False(promptedForVersion); } + [Fact] + public async Task InitCommandWithPrHivePrefersCurrentCliVersionWithoutChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + hivesDir.Create(); + hivesDir.CreateSubdirectory("pr-12345"); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + string? selectedVersion = null; + bool promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.NewCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestNewCommandPrompter(interactionService); + + prompter.PromptForTemplatesVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt for version when PR hives contain the current CLI version."); + }; + + return prompter; + }; + + options.PackagingServiceFactory = _ => + { + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => + { + var implicitCache = new CallbackNuGetPackageCache( + (dir, prerelease, nugetConfig, ct) => + { + var packages = new[] + { + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "99.0.0" }, + }; + + return Task.FromResult>(packages); + }); + var prCache = new CallbackNuGetPackageCache( + (dir, prerelease, nugetConfig, ct) => + { + var packages = new[] + { + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = cliVersion }, + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "98.0.0" }, + }; + + return Task.FromResult>(packages); + }); + + return Task.FromResult>( + [ + PackageChannel.CreateImplicitChannel(implicitCache), + PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], prCache) + ]); + } + }; + }; + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => + { + selectedVersion = version; + return (0, version); + }; + runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) => + { + var appHostFile = Path.Combine(outputPath, "apphost.cs"); + File.WriteAllText(appHostFile, "// Test apphost file"); + return 0; + }; + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Equal(cliVersion, selectedVersion); + Assert.False(promptedForVersion); + } + [Fact] public async Task InitCommandWithInvalidChannelShowsError() { diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs new file mode 100644 index 00000000000..0adb81c53d4 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class VersionHelperTests +{ + [Fact] + public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentCliVersion() + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: null, + hasPrHives: true); + + Assert.True(result); + Assert.Equal(cliVersion, match); + } +} From 36327f33ca9f32ac4ae8d1ee5bd2cda93ff1a210 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 14 Apr 2026 10:31:19 -0700 Subject: [PATCH 09/25] Fix PR hive package mappings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/AddCommandTests.cs | 23 ++++++++- .../Packaging/PackageChannelTests.cs | 50 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 2c5e9f28c55..dd7655bd100 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -897,7 +898,7 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); hivesDir.Create(); var prHiveDir = hivesDir.CreateSubdirectory("pr-12345"); - prHiveDir.CreateSubdirectory("packages"); + var packagesDir = prHiveDir.CreateSubdirectory("packages"); var cliVersion = VersionHelper.GetDefaultSdkVersion(); var expectedSource = Path.Combine(prHiveDir.FullName, "packages").Replace('\\', '/'); @@ -905,6 +906,8 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); File.WriteAllText(appHostFile.FullName, ""); + CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion); + CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -961,8 +964,26 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS var nuGetConfigContents = File.ReadAllText(nuGetConfigPath); Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); + Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } + + private static void CreatePackage(string directory, string packageId, string version) + { + var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); + using var writer = new StreamWriter(nuspecEntry.Open()); + writer.Write($$""" + + + + {{packageId}} + {{version}} + + + """); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 3c1d66bfab2..fdb1f9e5247 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Resources; @@ -106,4 +107,53 @@ public void SourceDetails_EmptyMappingsArray_ReturnsBasedOnNuGetConfig() Assert.Equal(PackagingStrings.BasedOnNuGetConfig, channel.SourceDetails); Assert.Equal(PackageChannelType.Explicit, channel.Type); } + + [Fact] + public void CreateScopedChannelForPackage_PrHiveExpandsToAllPackagesInHive() + { + var cache = new FakeNuGetPackageCache(); + var tempDir = Directory.CreateTempSubdirectory(); + try + { + CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.Hosting", "13.3.0-pr.16125.g5bef2f2f"); + + var mappings = new[] + { + new PackageMapping("Aspire*", tempDir.FullName.Replace('\\', '/')), + new PackageMapping("*", "https://api.nuget.org/v3/index.json") + }; + + var channel = PackageChannel.CreateExplicitChannel("pr-16125", PackageChannelQuality.Prerelease, mappings, cache); + + var scopedChannel = channel.CreateScopedChannelForPackage("Aspire.Hosting.Redis"); + + var packageFilters = scopedChannel.Mappings!.Select(mapping => mapping.PackageFilter).ToArray(); + Assert.Contains("Aspire.Hosting.Redis", packageFilters); + Assert.Contains("Aspire.Hosting", packageFilters); + Assert.Contains("*", packageFilters); + Assert.DoesNotContain("Aspire*", packageFilters); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + private static void CreatePackage(string directory, string packageId, string version) + { + var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); + using var writer = new StreamWriter(nuspecEntry.Open()); + writer.Write($$""" + + + + {{packageId}} + {{version}} + + + """); + } } From 596c1caebe5f7b27f67646ade837b7516ccea44d Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 15 Apr 2026 10:05:24 -0700 Subject: [PATCH 10/25] Simplify PR hive package detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackageChannel.cs | 42 +++++++------------ .../Commands/AddCommandTests.cs | 14 +------ .../Packaging/PackageChannelTests.cs | 14 +------ 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 2ac6b9cd5d3..80dee9132ad 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; -using System.Xml.Linq; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; using Aspire.Cli.Utils; @@ -245,7 +243,7 @@ private static HashSet GetScopedPackageIds(string source) foreach (var packageFile in Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly)) { - if (TryGetPackageId(packageFile) is { Length: > 0 } packageId) + if (TryGetPackageIdFromPackageFileName(packageFile) is { Length: > 0 } packageId) { packageIds.Add(packageId); } @@ -254,35 +252,27 @@ private static HashSet GetScopedPackageIds(string source) return packageIds; } - private static string? TryGetPackageId(string packageFile) + private static string? TryGetPackageIdFromPackageFileName(string packageFile) { - try - { - using var archive = ZipFile.OpenRead(packageFile); - var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); - if (nuspecEntry is null) - { - return null; - } - - using var stream = nuspecEntry.Open(); - var document = XDocument.Load(stream); - var metadata = document.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "metadata"); - var id = metadata?.Elements().FirstOrDefault(e => e.Name.LocalName == "id")?.Value; - return string.IsNullOrWhiteSpace(id) ? null : id; - } - catch (IOException) - { - return null; - } - catch (InvalidDataException) + var packageFileName = Path.GetFileNameWithoutExtension(packageFile); + if (string.IsNullOrWhiteSpace(packageFileName)) { return null; } - catch (System.Xml.XmlException) + + var separatorIndex = packageFileName.IndexOf('.'); + while (separatorIndex >= 0 && separatorIndex < packageFileName.Length - 1) { - return null; + var versionCandidate = packageFileName[(separatorIndex + 1)..]; + if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out _)) + { + return packageFileName[..separatorIndex]; + } + + separatorIndex = packageFileName.IndexOf('.', separatorIndex + 1); } + + return null; } private static bool IsScopedAspireMapping(PackageMapping mapping) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index dd7655bd100..397ba08532d 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -971,18 +970,7 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS private static void CreatePackage(string directory, string packageId, string version) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); - using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); - var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); - using var writer = new StreamWriter(nuspecEntry.Open()); - writer.Write($$""" - - - - {{packageId}} - {{version}} - - - """); + File.WriteAllText(packagePath, string.Empty); } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index fdb1f9e5247..6ace2172836 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Resources; @@ -143,17 +142,6 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToAllPackagesInHive() private static void CreatePackage(string directory, string packageId, string version) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); - using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); - var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); - using var writer = new StreamWriter(nuspecEntry.Open()); - writer.Write($$""" - - - - {{packageId}} - {{version}} - - - """); + File.WriteAllText(packagePath, string.Empty); } } From 360f280553e16323aa2d354c127b1822a7f702cc Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 15 Apr 2026 10:18:00 -0700 Subject: [PATCH 11/25] Restore aspire add E2E timeout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/KubernetesDeployTestHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index bf82cabdce4..5ee7159edbb 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -430,7 +430,7 @@ await auto.WaitUntilAsync( return false; }, - timeout: TimeSpan.FromSeconds(60), + timeout: TimeSpan.FromSeconds(180), description: $"aspire add version prompt or completion [{counter.Value} OK/ERR] $"); if (showedVersionPrompt) From ffd8112612f825968190dc71c7e6c3d659a6fc1e Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 15 Apr 2026 17:33:43 -0700 Subject: [PATCH 12/25] Scope PR hive mappings to package dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackageChannel.cs | 98 ++++++++++++++++--- .../Commands/AddCommandTests.cs | 35 ++++++- .../Packaging/PackageChannelTests.cs | 37 ++++++- 3 files changed, 149 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 80dee9132ad..509bcc3fd76 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; +using System.Xml.Linq; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; using Aspire.Cli.Utils; @@ -219,12 +221,7 @@ private static IEnumerable CreateScopedMappings(PackageMapping m yield break; } - var packageIds = GetScopedPackageIds(mapping.Source); - if (packageIds.Count == 0) - { - yield return new PackageMapping(packageId, mapping.Source); - yield break; - } + var packageIds = GetScopedPackageIds(mapping.Source, packageId); foreach (var scopedPackageId in packageIds) { @@ -232,27 +229,98 @@ private static IEnumerable CreateScopedMappings(PackageMapping m } } - private static HashSet GetScopedPackageIds(string source) + private static HashSet GetScopedPackageIds(string source, string packageId) { - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + packageId + }; if (!Directory.Exists(source)) { return packageIds; } - foreach (var packageFile in Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly)) + var packageFiles = Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly) + .Select(GetPackageFileMetadata) + .OfType() + .GroupBy(metadata => metadata.PackageId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(), + StringComparer.OrdinalIgnoreCase); + + var packagesToProcess = new Queue(); + packagesToProcess.Enqueue(packageId); + + while (packagesToProcess.Count > 0) { - if (TryGetPackageIdFromPackageFileName(packageFile) is { Length: > 0 } packageId) + var currentPackageId = packagesToProcess.Dequeue(); + if (!packageFiles.TryGetValue(currentPackageId, out var metadata)) + { + continue; + } + + foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath)) { - packageIds.Add(packageId); + if (packageFiles.ContainsKey(dependencyPackageId) && packageIds.Add(dependencyPackageId)) + { + packagesToProcess.Enqueue(dependencyPackageId); + } } } return packageIds; } - private static string? TryGetPackageIdFromPackageFileName(string packageFile) + private static PackageFileMetadata? GetPackageFileMetadata(string packageFile) + { + var packageIdentity = TryGetPackageIdentityFromPackageFileName(packageFile); + if (packageIdentity is null) + { + return null; + } + + return new PackageFileMetadata(packageIdentity.Value.PackageId, packageIdentity.Value.Version, packageFile); + } + + private static IEnumerable GetDependencyPackageIds(string packageFile) + { + try + { + using var archive = ZipFile.OpenRead(packageFile); + var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + if (nuspecEntry is null) + { + return []; + } + + using var stream = nuspecEntry.Open(); + var document = XDocument.Load(stream); + return document + .Descendants() + .Where(element => element.Name.LocalName == "dependency") + .Select(element => element.Attribute("id")?.Value) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Cast() + .ToArray(); + } + catch (IOException) + { + return []; + } + catch (InvalidDataException) + { + return []; + } + catch (System.Xml.XmlException) + { + return []; + } + } + + private static (string PackageId, SemVersion Version)? TryGetPackageIdentityFromPackageFileName(string packageFile) { var packageFileName = Path.GetFileNameWithoutExtension(packageFile); if (string.IsNullOrWhiteSpace(packageFileName)) @@ -264,9 +332,9 @@ private static HashSet GetScopedPackageIds(string source) while (separatorIndex >= 0 && separatorIndex < packageFileName.Length - 1) { var versionCandidate = packageFileName[(separatorIndex + 1)..]; - if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out _)) + if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out var version)) { - return packageFileName[..separatorIndex]; + return (packageFileName[..separatorIndex], version); } separatorIndex = packageFileName.IndexOf('.', separatorIndex + 1); @@ -275,6 +343,8 @@ private static HashSet GetScopedPackageIds(string source) return null; } + private readonly record struct PackageFileMetadata(string PackageId, SemVersion Version, string PackageFilePath); + private static bool IsScopedAspireMapping(PackageMapping mapping) { return mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) && diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 397ba08532d..074d078869f 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -905,8 +906,9 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); File.WriteAllText(appHostFile.FullName, ""); - CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion); + CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion, "Aspire.Hosting"); CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); + CreatePackage(packagesDir.FullName, "Aspire.Hosting.AppHost", cliVersion); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -964,13 +966,40 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); + Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } - private static void CreatePackage(string directory, string packageId, string version) + private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); - File.WriteAllText(packagePath, string.Empty); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); + using var writer = new StreamWriter(nuspecEntry.Open()); + + writer.Write($$""" + + + + {{packageId}} + {{version}} + + """); + + foreach (var dependency in dependencies) + { + writer.Write($$""" + + + + """); + } + + writer.Write(""" + + + + """); } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 6ace2172836..42d3afb7792 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Resources; @@ -108,14 +109,15 @@ public void SourceDetails_EmptyMappingsArray_ReturnsBasedOnNuGetConfig() } [Fact] - public void CreateScopedChannelForPackage_PrHiveExpandsToAllPackagesInHive() + public void CreateScopedChannelForPackage_PrHiveExpandsToTransitivePackagesInHive() { var cache = new FakeNuGetPackageCache(); var tempDir = Directory.CreateTempSubdirectory(); try { - CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f", "Aspire.Hosting"); CreatePackage(tempDir.FullName, "Aspire.Hosting", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.Hosting.AppHost", "13.3.0-pr.16125.g5bef2f2f"); var mappings = new[] { @@ -130,6 +132,7 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToAllPackagesInHive() var packageFilters = scopedChannel.Mappings!.Select(mapping => mapping.PackageFilter).ToArray(); Assert.Contains("Aspire.Hosting.Redis", packageFilters); Assert.Contains("Aspire.Hosting", packageFilters); + Assert.DoesNotContain("Aspire.Hosting.AppHost", packageFilters); Assert.Contains("*", packageFilters); Assert.DoesNotContain("Aspire*", packageFilters); } @@ -139,9 +142,35 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToAllPackagesInHive() } } - private static void CreatePackage(string directory, string packageId, string version) + private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); - File.WriteAllText(packagePath, string.Empty); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); + using var writer = new StreamWriter(nuspecEntry.Open()); + + writer.Write($$""" + + + + {{packageId}} + {{version}} + + """); + + foreach (var dependency in dependencies) + { + writer.Write($$""" + + + + """); + } + + writer.Write(""" + + + + """); } } From ab9af5c5c985d84b54ba258f9f97049ffbaeda49 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 16 Apr 2026 16:10:46 -0700 Subject: [PATCH 13/25] Keep AppHost SDK in scoped PR hives Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 39 +++++++++++++++++- src/Aspire.Cli/Packaging/PackageChannel.cs | 41 ++++++++++++------- .../Commands/AddCommandTests.cs | 4 +- .../Packaging/PackageChannelTests.cs | 36 ++++++++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index dc3b6a8fba6..8af58d6e1fc 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -28,6 +28,7 @@ internal sealed class AddCommand : BaseCommand private readonly IDotNetSdkInstaller _sdkInstaller; private readonly ICliHostEnvironment _hostEnvironment; private readonly IAppHostProjectFactory _projectFactory; + private readonly FallbackProjectParser _fallbackProjectParser; private static readonly Argument s_integrationArgument = new("integration") { @@ -44,7 +45,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.SourceArgumentDescription }; - public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) + public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, FallbackProjectParser fallbackProjectParser) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _packagingService = packagingService; @@ -53,6 +54,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _projectFactory = projectFactory; + _fallbackProjectParser = fallbackProjectParser; Arguments.Add(s_integrationArgument); Options.Add(s_appHostOption); @@ -211,9 +213,13 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) { var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); + var scopedPackageIds = GetScopedPrHivePackageIds( + effectiveAppHostProjectFile, + selectedNuGetPackage.Package.Id, + selectedNuGetPackage.Package.Version); await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel.CreateScopedChannelForPackage(selectedNuGetPackage.Package.Id), + selectedNuGetPackage.Channel.CreateScopedChannelForPackages(scopedPackageIds), cancellationToken); } @@ -364,6 +370,35 @@ internal static (string FriendlyName, NuGetPackage Package, PackageChannel Chann return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel); } + + private IReadOnlyCollection GetScopedPrHivePackageIds(FileInfo appHostProjectFile, string selectedPackageId, string selectedPackageVersion) + { + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + selectedPackageId + }; + + if (!appHostProjectFile.Exists) + { + return packageIds; + } + + using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); + if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || + !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) + { + return packageIds; + } + + var sdkVersion = sdkVersionElement.GetString(); + if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) + { + return packageIds; + } + + packageIds.Add("Aspire.AppHost.Sdk"); + return packageIds; + } } internal interface IAddCommandPrompter diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 509bcc3fd76..66c060cbc99 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -198,7 +198,22 @@ public async Task> GetPackagesAsync(string packageId, public PackageChannel CreateScopedChannelForPackage(string packageId) { - ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + return CreateScopedChannelForPackages([packageId]); + } + + public PackageChannel CreateScopedChannelForPackages(IEnumerable packageIds) + { + ArgumentNullException.ThrowIfNull(packageIds); + + var requestedPackageIds = packageIds + .Where(packageId => !string.IsNullOrWhiteSpace(packageId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (requestedPackageIds.Length == 0) + { + throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds)); + } var mappings = Mappings; if (!VersionHelper.IsPrChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 }) @@ -207,13 +222,13 @@ public PackageChannel CreateScopedChannelForPackage(string packageId) } var scopedMappings = mappings - .SelectMany(mapping => CreateScopedMappings(mapping, packageId)) + .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds)) .ToArray(); return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion); } - private static IEnumerable CreateScopedMappings(PackageMapping mapping, string packageId) + private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds) { if (!IsScopedAspireMapping(mapping)) { @@ -221,24 +236,21 @@ private static IEnumerable CreateScopedMappings(PackageMapping m yield break; } - var packageIds = GetScopedPackageIds(mapping.Source, packageId); + var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds); - foreach (var scopedPackageId in packageIds) + foreach (var scopedPackageId in scopedPackageIds) { yield return new PackageMapping(scopedPackageId, mapping.Source); } } - private static HashSet GetScopedPackageIds(string source, string packageId) + private static HashSet GetScopedPackageIds(string source, IEnumerable packageIds) { - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) - { - packageId - }; + var resolvedPackageIds = new HashSet(packageIds, StringComparer.OrdinalIgnoreCase); if (!Directory.Exists(source)) { - return packageIds; + return resolvedPackageIds; } var packageFiles = Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly) @@ -250,8 +262,7 @@ private static HashSet GetScopedPackageIds(string source, string package group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(), StringComparer.OrdinalIgnoreCase); - var packagesToProcess = new Queue(); - packagesToProcess.Enqueue(packageId); + var packagesToProcess = new Queue(resolvedPackageIds); while (packagesToProcess.Count > 0) { @@ -263,14 +274,14 @@ private static HashSet GetScopedPackageIds(string source, string package foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath)) { - if (packageFiles.ContainsKey(dependencyPackageId) && packageIds.Add(dependencyPackageId)) + if (packageFiles.ContainsKey(dependencyPackageId) && resolvedPackageIds.Add(dependencyPackageId)) { packagesToProcess.Enqueue(dependencyPackageId); } } } - return packageIds; + return resolvedPackageIds; } private static PackageFileMetadata? GetPackageFileMetadata(string packageFile) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 074d078869f..91b183bfd37 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -905,9 +905,10 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS string? addUsedSource = null; var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); - File.WriteAllText(appHostFile.FullName, ""); + File.WriteAllText(appHostFile.FullName, $$""""""); CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion, "Aspire.Hosting"); CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); + CreatePackage(packagesDir.FullName, "Aspire.AppHost.Sdk", cliVersion); CreatePackage(packagesDir.FullName, "Aspire.Hosting.AppHost", cliVersion); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -966,6 +967,7 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); + Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 42d3afb7792..f3ad33ee420 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -142,6 +142,42 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToTransitivePackagesInHiv } } + [Fact] + public void CreateScopedChannelForPackages_PrHiveIncludesExplicitRootPackages() + { + var cache = new FakeNuGetPackageCache(); + var tempDir = Directory.CreateTempSubdirectory(); + try + { + CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f", "Aspire.Hosting"); + CreatePackage(tempDir.FullName, "Aspire.Hosting", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.AppHost.Sdk", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.Hosting.AppHost", "13.3.0-pr.16125.g5bef2f2f"); + + var mappings = new[] + { + new PackageMapping("Aspire*", tempDir.FullName.Replace('\\', '/')), + new PackageMapping("*", "https://api.nuget.org/v3/index.json") + }; + + var channel = PackageChannel.CreateExplicitChannel("pr-16125", PackageChannelQuality.Prerelease, mappings, cache); + + var scopedChannel = channel.CreateScopedChannelForPackages(["Aspire.Hosting.Redis", "Aspire.AppHost.Sdk"]); + + var packageFilters = scopedChannel.Mappings!.Select(mapping => mapping.PackageFilter).ToArray(); + Assert.Contains("Aspire.Hosting.Redis", packageFilters); + Assert.Contains("Aspire.Hosting", packageFilters); + Assert.Contains("Aspire.AppHost.Sdk", packageFilters); + Assert.DoesNotContain("Aspire.Hosting.AppHost", packageFilters); + Assert.Contains("*", packageFilters); + Assert.DoesNotContain("Aspire*", packageFilters); + } + finally + { + tempDir.Delete(recursive: true); + } + } + private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); From 80dc3a2738c08f9c9a3d98ab283561facee77a4d Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 16 Apr 2026 16:48:25 -0700 Subject: [PATCH 14/25] Revert "Keep AppHost SDK in scoped PR hives" This reverts commit ab9af5c5c985d84b54ba258f9f97049ffbaeda49. --- src/Aspire.Cli/Commands/AddCommand.cs | 39 +----------------- src/Aspire.Cli/Packaging/PackageChannel.cs | 41 +++++++------------ .../Commands/AddCommandTests.cs | 4 +- .../Packaging/PackageChannelTests.cs | 36 ---------------- 4 files changed, 18 insertions(+), 102 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 8af58d6e1fc..dc3b6a8fba6 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -28,7 +28,6 @@ internal sealed class AddCommand : BaseCommand private readonly IDotNetSdkInstaller _sdkInstaller; private readonly ICliHostEnvironment _hostEnvironment; private readonly IAppHostProjectFactory _projectFactory; - private readonly FallbackProjectParser _fallbackProjectParser; private static readonly Argument s_integrationArgument = new("integration") { @@ -45,7 +44,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.SourceArgumentDescription }; - public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, FallbackProjectParser fallbackProjectParser) + public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _packagingService = packagingService; @@ -54,7 +53,6 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _projectFactory = projectFactory; - _fallbackProjectParser = fallbackProjectParser; Arguments.Add(s_integrationArgument); Options.Add(s_appHostOption); @@ -213,13 +211,9 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) { var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); - var scopedPackageIds = GetScopedPrHivePackageIds( - effectiveAppHostProjectFile, - selectedNuGetPackage.Package.Id, - selectedNuGetPackage.Package.Version); await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel.CreateScopedChannelForPackages(scopedPackageIds), + selectedNuGetPackage.Channel.CreateScopedChannelForPackage(selectedNuGetPackage.Package.Id), cancellationToken); } @@ -370,35 +364,6 @@ internal static (string FriendlyName, NuGetPackage Package, PackageChannel Chann return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel); } - - private IReadOnlyCollection GetScopedPrHivePackageIds(FileInfo appHostProjectFile, string selectedPackageId, string selectedPackageVersion) - { - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) - { - selectedPackageId - }; - - if (!appHostProjectFile.Exists) - { - return packageIds; - } - - using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); - if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || - !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) - { - return packageIds; - } - - var sdkVersion = sdkVersionElement.GetString(); - if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) - { - return packageIds; - } - - packageIds.Add("Aspire.AppHost.Sdk"); - return packageIds; - } } internal interface IAddCommandPrompter diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 66c060cbc99..509bcc3fd76 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -198,22 +198,7 @@ public async Task> GetPackagesAsync(string packageId, public PackageChannel CreateScopedChannelForPackage(string packageId) { - return CreateScopedChannelForPackages([packageId]); - } - - public PackageChannel CreateScopedChannelForPackages(IEnumerable packageIds) - { - ArgumentNullException.ThrowIfNull(packageIds); - - var requestedPackageIds = packageIds - .Where(packageId => !string.IsNullOrWhiteSpace(packageId)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (requestedPackageIds.Length == 0) - { - throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds)); - } + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); var mappings = Mappings; if (!VersionHelper.IsPrChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 }) @@ -222,13 +207,13 @@ public PackageChannel CreateScopedChannelForPackages(IEnumerable package } var scopedMappings = mappings - .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds)) + .SelectMany(mapping => CreateScopedMappings(mapping, packageId)) .ToArray(); return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion); } - private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds) + private static IEnumerable CreateScopedMappings(PackageMapping mapping, string packageId) { if (!IsScopedAspireMapping(mapping)) { @@ -236,21 +221,24 @@ private static IEnumerable CreateScopedMappings(PackageMapping m yield break; } - var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds); + var packageIds = GetScopedPackageIds(mapping.Source, packageId); - foreach (var scopedPackageId in scopedPackageIds) + foreach (var scopedPackageId in packageIds) { yield return new PackageMapping(scopedPackageId, mapping.Source); } } - private static HashSet GetScopedPackageIds(string source, IEnumerable packageIds) + private static HashSet GetScopedPackageIds(string source, string packageId) { - var resolvedPackageIds = new HashSet(packageIds, StringComparer.OrdinalIgnoreCase); + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + packageId + }; if (!Directory.Exists(source)) { - return resolvedPackageIds; + return packageIds; } var packageFiles = Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly) @@ -262,7 +250,8 @@ private static HashSet GetScopedPackageIds(string source, IEnumerable group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(), StringComparer.OrdinalIgnoreCase); - var packagesToProcess = new Queue(resolvedPackageIds); + var packagesToProcess = new Queue(); + packagesToProcess.Enqueue(packageId); while (packagesToProcess.Count > 0) { @@ -274,14 +263,14 @@ private static HashSet GetScopedPackageIds(string source, IEnumerable"""); + File.WriteAllText(appHostFile.FullName, ""); CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion, "Aspire.Hosting"); CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); - CreatePackage(packagesDir.FullName, "Aspire.AppHost.Sdk", cliVersion); CreatePackage(packagesDir.FullName, "Aspire.Hosting.AppHost", cliVersion); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -967,7 +966,6 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); - Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index f3ad33ee420..42d3afb7792 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -142,42 +142,6 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToTransitivePackagesInHiv } } - [Fact] - public void CreateScopedChannelForPackages_PrHiveIncludesExplicitRootPackages() - { - var cache = new FakeNuGetPackageCache(); - var tempDir = Directory.CreateTempSubdirectory(); - try - { - CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f", "Aspire.Hosting"); - CreatePackage(tempDir.FullName, "Aspire.Hosting", "13.3.0-pr.16125.g5bef2f2f"); - CreatePackage(tempDir.FullName, "Aspire.AppHost.Sdk", "13.3.0-pr.16125.g5bef2f2f"); - CreatePackage(tempDir.FullName, "Aspire.Hosting.AppHost", "13.3.0-pr.16125.g5bef2f2f"); - - var mappings = new[] - { - new PackageMapping("Aspire*", tempDir.FullName.Replace('\\', '/')), - new PackageMapping("*", "https://api.nuget.org/v3/index.json") - }; - - var channel = PackageChannel.CreateExplicitChannel("pr-16125", PackageChannelQuality.Prerelease, mappings, cache); - - var scopedChannel = channel.CreateScopedChannelForPackages(["Aspire.Hosting.Redis", "Aspire.AppHost.Sdk"]); - - var packageFilters = scopedChannel.Mappings!.Select(mapping => mapping.PackageFilter).ToArray(); - Assert.Contains("Aspire.Hosting.Redis", packageFilters); - Assert.Contains("Aspire.Hosting", packageFilters); - Assert.Contains("Aspire.AppHost.Sdk", packageFilters); - Assert.DoesNotContain("Aspire.Hosting.AppHost", packageFilters); - Assert.Contains("*", packageFilters); - Assert.DoesNotContain("Aspire*", packageFilters); - } - finally - { - tempDir.Delete(recursive: true); - } - } - private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); From faa7933c9dd2be4cbc09f2bcf435837874b72505 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 16 Apr 2026 16:55:50 -0700 Subject: [PATCH 15/25] Keep AppHost SDK in scoped PR hives Reproduced the missing SDK source-mapping failure locally before reapplying this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 39 +++++++++++++++++- src/Aspire.Cli/Packaging/PackageChannel.cs | 41 ++++++++++++------- .../Commands/AddCommandTests.cs | 4 +- .../Packaging/PackageChannelTests.cs | 36 ++++++++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index dc3b6a8fba6..8af58d6e1fc 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -28,6 +28,7 @@ internal sealed class AddCommand : BaseCommand private readonly IDotNetSdkInstaller _sdkInstaller; private readonly ICliHostEnvironment _hostEnvironment; private readonly IAppHostProjectFactory _projectFactory; + private readonly FallbackProjectParser _fallbackProjectParser; private static readonly Argument s_integrationArgument = new("integration") { @@ -44,7 +45,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.SourceArgumentDescription }; - public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) + public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, FallbackProjectParser fallbackProjectParser) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _packagingService = packagingService; @@ -53,6 +54,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _projectFactory = projectFactory; + _fallbackProjectParser = fallbackProjectParser; Arguments.Add(s_integrationArgument); Options.Add(s_appHostOption); @@ -211,9 +213,13 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) { var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); + var scopedPackageIds = GetScopedPrHivePackageIds( + effectiveAppHostProjectFile, + selectedNuGetPackage.Package.Id, + selectedNuGetPackage.Package.Version); await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel.CreateScopedChannelForPackage(selectedNuGetPackage.Package.Id), + selectedNuGetPackage.Channel.CreateScopedChannelForPackages(scopedPackageIds), cancellationToken); } @@ -364,6 +370,35 @@ internal static (string FriendlyName, NuGetPackage Package, PackageChannel Chann return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel); } + + private IReadOnlyCollection GetScopedPrHivePackageIds(FileInfo appHostProjectFile, string selectedPackageId, string selectedPackageVersion) + { + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + selectedPackageId + }; + + if (!appHostProjectFile.Exists) + { + return packageIds; + } + + using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); + if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || + !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) + { + return packageIds; + } + + var sdkVersion = sdkVersionElement.GetString(); + if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) + { + return packageIds; + } + + packageIds.Add("Aspire.AppHost.Sdk"); + return packageIds; + } } internal interface IAddCommandPrompter diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 509bcc3fd76..66c060cbc99 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -198,7 +198,22 @@ public async Task> GetPackagesAsync(string packageId, public PackageChannel CreateScopedChannelForPackage(string packageId) { - ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + return CreateScopedChannelForPackages([packageId]); + } + + public PackageChannel CreateScopedChannelForPackages(IEnumerable packageIds) + { + ArgumentNullException.ThrowIfNull(packageIds); + + var requestedPackageIds = packageIds + .Where(packageId => !string.IsNullOrWhiteSpace(packageId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (requestedPackageIds.Length == 0) + { + throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds)); + } var mappings = Mappings; if (!VersionHelper.IsPrChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 }) @@ -207,13 +222,13 @@ public PackageChannel CreateScopedChannelForPackage(string packageId) } var scopedMappings = mappings - .SelectMany(mapping => CreateScopedMappings(mapping, packageId)) + .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds)) .ToArray(); return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion); } - private static IEnumerable CreateScopedMappings(PackageMapping mapping, string packageId) + private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds) { if (!IsScopedAspireMapping(mapping)) { @@ -221,24 +236,21 @@ private static IEnumerable CreateScopedMappings(PackageMapping m yield break; } - var packageIds = GetScopedPackageIds(mapping.Source, packageId); + var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds); - foreach (var scopedPackageId in packageIds) + foreach (var scopedPackageId in scopedPackageIds) { yield return new PackageMapping(scopedPackageId, mapping.Source); } } - private static HashSet GetScopedPackageIds(string source, string packageId) + private static HashSet GetScopedPackageIds(string source, IEnumerable packageIds) { - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) - { - packageId - }; + var resolvedPackageIds = new HashSet(packageIds, StringComparer.OrdinalIgnoreCase); if (!Directory.Exists(source)) { - return packageIds; + return resolvedPackageIds; } var packageFiles = Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly) @@ -250,8 +262,7 @@ private static HashSet GetScopedPackageIds(string source, string package group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(), StringComparer.OrdinalIgnoreCase); - var packagesToProcess = new Queue(); - packagesToProcess.Enqueue(packageId); + var packagesToProcess = new Queue(resolvedPackageIds); while (packagesToProcess.Count > 0) { @@ -263,14 +274,14 @@ private static HashSet GetScopedPackageIds(string source, string package foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath)) { - if (packageFiles.ContainsKey(dependencyPackageId) && packageIds.Add(dependencyPackageId)) + if (packageFiles.ContainsKey(dependencyPackageId) && resolvedPackageIds.Add(dependencyPackageId)) { packagesToProcess.Enqueue(dependencyPackageId); } } } - return packageIds; + return resolvedPackageIds; } private static PackageFileMetadata? GetPackageFileMetadata(string packageFile) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 074d078869f..91b183bfd37 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -905,9 +905,10 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS string? addUsedSource = null; var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); - File.WriteAllText(appHostFile.FullName, ""); + File.WriteAllText(appHostFile.FullName, $$""""""); CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion, "Aspire.Hosting"); CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); + CreatePackage(packagesDir.FullName, "Aspire.AppHost.Sdk", cliVersion); CreatePackage(packagesDir.FullName, "Aspire.Hosting.AppHost", cliVersion); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -966,6 +967,7 @@ public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleS Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); + Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 42d3afb7792..f3ad33ee420 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -142,6 +142,42 @@ public void CreateScopedChannelForPackage_PrHiveExpandsToTransitivePackagesInHiv } } + [Fact] + public void CreateScopedChannelForPackages_PrHiveIncludesExplicitRootPackages() + { + var cache = new FakeNuGetPackageCache(); + var tempDir = Directory.CreateTempSubdirectory(); + try + { + CreatePackage(tempDir.FullName, "Aspire.Hosting.Redis", "13.3.0-pr.16125.g5bef2f2f", "Aspire.Hosting"); + CreatePackage(tempDir.FullName, "Aspire.Hosting", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.AppHost.Sdk", "13.3.0-pr.16125.g5bef2f2f"); + CreatePackage(tempDir.FullName, "Aspire.Hosting.AppHost", "13.3.0-pr.16125.g5bef2f2f"); + + var mappings = new[] + { + new PackageMapping("Aspire*", tempDir.FullName.Replace('\\', '/')), + new PackageMapping("*", "https://api.nuget.org/v3/index.json") + }; + + var channel = PackageChannel.CreateExplicitChannel("pr-16125", PackageChannelQuality.Prerelease, mappings, cache); + + var scopedChannel = channel.CreateScopedChannelForPackages(["Aspire.Hosting.Redis", "Aspire.AppHost.Sdk"]); + + var packageFilters = scopedChannel.Mappings!.Select(mapping => mapping.PackageFilter).ToArray(); + Assert.Contains("Aspire.Hosting.Redis", packageFilters); + Assert.Contains("Aspire.Hosting", packageFilters); + Assert.Contains("Aspire.AppHost.Sdk", packageFilters); + Assert.DoesNotContain("Aspire.Hosting.AppHost", packageFilters); + Assert.Contains("*", packageFilters); + Assert.DoesNotContain("Aspire*", packageFilters); + } + finally + { + tempDir.Delete(recursive: true); + } + } + private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) { var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); From 5445cda711ef4c2d5a7ac26ab25b1e1925c38d11 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 20 Apr 2026 18:56:16 +1000 Subject: [PATCH 16/25] Address JamesNK review feedback on PR #16125 - Wrap ParseProject in try-catch for ProjectUpdaterException in AddCommand.GetScopedPrHivePackageIds so a malformed project file does not crash the entire 'aspire add' command (best-effort check). - Add NuGetPackageId to the shared StringComparers/StringComparisons file and use it consistently in PackageChannel.cs for all NuGet package ID comparisons instead of inline StringComparer.OrdinalIgnoreCase. - Add debug-level logging to the catch blocks in PackageChannel.GetDependencyPackageIds so IO, invalid data, and XML parse errors are no longer silently swallowed. Thread an optional ILogger through PackageChannel and wire it from PackagingService via DI. - Extract the file-local CallbackNuGetPackageCache from InitCommandTests into a shared test service under TestServices/ to reduce duplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 26 ++++++---- src/Aspire.Cli/Packaging/PackageChannel.cs | 50 ++++++++++--------- src/Aspire.Cli/Packaging/PackagingService.cs | 13 ++--- src/Shared/StringComparers.cs | 2 + .../Commands/InitCommandTests.cs | 24 --------- .../NuGetConfigMergerSnapshotTests.cs | 3 +- .../Packaging/PackagingServiceTests.cs | 47 +++++++++-------- .../TestServices/CallbackNuGetPackageCache.cs | 25 ++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- 9 files changed, 106 insertions(+), 86 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/CallbackNuGetPackageCache.cs diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 8af58d6e1fc..329e83e395f 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -383,20 +383,28 @@ private IReadOnlyCollection GetScopedPrHivePackageIds(FileInfo appHostPr return packageIds; } - using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); - if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || - !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) + try { - return packageIds; - } + using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); + if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || + !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) + { + return packageIds; + } - var sdkVersion = sdkVersionElement.GetString(); - if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) + var sdkVersion = sdkVersionElement.GetString(); + if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) + { + return packageIds; + } + + packageIds.Add("Aspire.AppHost.Sdk"); + } + catch (ProjectUpdaterException) { - return packageIds; + // Parsing the project file is best-effort; if it fails, just return the selected package. } - packageIds.Add("Aspire.AppHost.Sdk"); return packageIds; } } diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 66c060cbc99..fdc179e60e0 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -6,12 +6,13 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; using Semver; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -141,7 +142,7 @@ public async Task> GetPackagesAsync(string packageId, tasks.Add(nuGetPackageCache.GetPackagesAsync( workingDirectory: workingDirectory, packageId: packageId, - filter: id => id.Equals(packageId, StringComparison.OrdinalIgnoreCase), + filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId), prerelease: false, nugetConfigFile: tempNuGetConfig?.ConfigFile, useCache: true, // Enable caching for package channel resolution @@ -153,7 +154,7 @@ public async Task> GetPackagesAsync(string packageId, tasks.Add(nuGetPackageCache.GetPackagesAsync( workingDirectory: workingDirectory, packageId: packageId, - filter: id => id.Equals(packageId, StringComparison.OrdinalIgnoreCase), + filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId), prerelease: true, nugetConfigFile: tempNuGetConfig?.ConfigFile, useCache: true, // Enable caching for package channel resolution @@ -174,7 +175,7 @@ public async Task> GetPackagesAsync(string packageId, packages = await nuGetPackageCache.GetPackagesAsync( workingDirectory: workingDirectory, packageId: packageId, - filter: id => id.Equals(packageId, StringComparison.OrdinalIgnoreCase), + filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId), prerelease: true, nugetConfigFile: tempNuGetConfig?.ConfigFile, useCache: true, // Enable caching for package channel resolution @@ -207,7 +208,7 @@ public PackageChannel CreateScopedChannelForPackages(IEnumerable package var requestedPackageIds = packageIds .Where(packageId => !string.IsNullOrWhiteSpace(packageId)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparers.NuGetPackageId) .ToArray(); if (requestedPackageIds.Length == 0) @@ -222,13 +223,13 @@ public PackageChannel CreateScopedChannelForPackages(IEnumerable package } var scopedMappings = mappings - .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds)) + .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds, logger)) .ToArray(); - return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion); + return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion, logger); } - private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds) + private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds, ILogger? logger) { if (!IsScopedAspireMapping(mapping)) { @@ -236,7 +237,7 @@ private static IEnumerable CreateScopedMappings(PackageMapping m yield break; } - var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds); + var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds, logger); foreach (var scopedPackageId in scopedPackageIds) { @@ -244,9 +245,9 @@ private static IEnumerable CreateScopedMappings(PackageMapping m } } - private static HashSet GetScopedPackageIds(string source, IEnumerable packageIds) + private static HashSet GetScopedPackageIds(string source, IEnumerable packageIds, ILogger? logger) { - var resolvedPackageIds = new HashSet(packageIds, StringComparer.OrdinalIgnoreCase); + var resolvedPackageIds = new HashSet(packageIds, StringComparers.NuGetPackageId); if (!Directory.Exists(source)) { @@ -256,11 +257,11 @@ private static HashSet GetScopedPackageIds(string source, IEnumerable() - .GroupBy(metadata => metadata.PackageId, StringComparer.OrdinalIgnoreCase) + .GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId) .ToDictionary( group => group.Key, group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(), - StringComparer.OrdinalIgnoreCase); + StringComparers.NuGetPackageId); var packagesToProcess = new Queue(resolvedPackageIds); @@ -272,7 +273,7 @@ private static HashSet GetScopedPackageIds(string source, IEnumerable GetScopedPackageIds(string source, IEnumerable GetDependencyPackageIds(string packageFile) + private static IEnumerable GetDependencyPackageIds(string packageFile, ILogger? logger) { try { @@ -313,20 +314,23 @@ private static IEnumerable GetDependencyPackageIds(string packageFile) .Where(element => element.Name.LocalName == "dependency") .Select(element => element.Attribute("id")?.Value) .Where(id => !string.IsNullOrWhiteSpace(id)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparers.NuGetPackageId) .Cast() .ToArray(); } - catch (IOException) + catch (IOException ex) { + logger?.LogDebug(ex, "Failed to read package file '{PackageFile}' while resolving dependencies.", packageFile); return []; } - catch (InvalidDataException) + catch (InvalidDataException ex) { + logger?.LogDebug(ex, "Package file '{PackageFile}' contains invalid data.", packageFile); return []; } - catch (System.Xml.XmlException) + catch (System.Xml.XmlException ex) { + logger?.LogDebug(ex, "Failed to parse nuspec in package file '{PackageFile}'.", packageFile); return []; } } @@ -362,18 +366,18 @@ private static bool IsScopedAspireMapping(PackageMapping mapping) !string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal); } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion, logger); } - public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) + public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache, ILogger? logger = null) { // The reason that PackageChannelQuality.Both is because there are situations like // in community toolkit where there is a newer beta version available for a package // in the case of implicit feeds we want to be able to show that, along side the stable // version. Not really an issue for template selection though (unless we start allowing) // for broader templating options. - return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache); + return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache, logger: logger); } } diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index c08948a2d5b..654232ef78f 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -5,6 +5,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -14,22 +15,22 @@ internal interface IPackagingService public Task> GetChannelsAsync(CancellationToken cancellationToken = default); } -internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration) : IPackagingService +internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration, ILogger logger) : IPackagingService { public Task> GetChannelsAsync(CancellationToken cancellationToken = default) { - var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache); + var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache, logger); var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[] { new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); + }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily", logger: logger); var dailyChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily"); + }, nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily", logger: logger); var prPackageChannels = new List(); @@ -49,7 +50,7 @@ public Task> GetChannelsAsync(CancellationToken canc { new PackageMapping("Aspire*", packagesPath), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache); + }, nuGetPackageCache, logger: logger); prPackageChannels.Add(prChannel); } @@ -98,7 +99,7 @@ public Task> GetChannelsAsync(CancellationToken canc { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion, logger: logger); return stagingChannel; } diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs index b969c4b57ce..df5ed65c391 100644 --- a/src/Shared/StringComparers.cs +++ b/src/Shared/StringComparers.cs @@ -33,6 +33,7 @@ internal static class StringComparers public static StringComparer CliInputOrOutput => StringComparer.Ordinal; public static StringComparer InteractionInputName => StringComparer.OrdinalIgnoreCase; public static StringComparer NetworkID => StringComparer.Ordinal; + public static StringComparer NuGetPackageId => StringComparer.OrdinalIgnoreCase; } internal static class StringComparisons @@ -63,4 +64,5 @@ internal static class StringComparisons public static StringComparison CliInputOrOutput => StringComparison.Ordinal; public static StringComparison InteractionInputName => StringComparison.OrdinalIgnoreCase; public static StringComparison NetworkID => StringComparison.Ordinal; + public static StringComparison NuGetPackageId => StringComparison.OrdinalIgnoreCase; } diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 6b95b9b0512..a33767c0108 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -866,28 +866,4 @@ private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Actio return Task.FromResult>(Array.Empty()); } } - - private sealed class CallbackNuGetPackageCache( - Func>> getTemplatePackagesAsyncCallback) : INuGetPackageCache - { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return getTemplatePackagesAsyncCallback(workingDirectory, prerelease, nugetConfigFile, cancellationToken); - } - - public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - } } diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index 7c50bbb4055..e0f89e92111 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Packaging; @@ -34,7 +35,7 @@ private static PackagingService CreatePackagingService(CliExecutionContext execu { var features = new TestFeatures(); var configuration = new ConfigurationBuilder().Build(); - return new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + return new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); } private static async Task WriteConfigAsync(DirectoryInfo dir, string content) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 0a88f849e80..30577cb8b75 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; using System.Xml.Linq; namespace Aspire.Cli.Tests.Packaging; @@ -34,7 +35,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag var features = new TestFeatures(); var configuration = new ConfigurationBuilder().Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -78,7 +79,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -122,7 +123,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithOverrideFeed_Use }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -155,7 +156,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithAzureDevOpsFeedO }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -188,7 +189,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidOverrideF }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -220,7 +221,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityOverride_ }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -251,7 +252,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityBoth_Uses }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -282,7 +283,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidQuality_D }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -312,7 +313,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithoutQualityOverri }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -343,7 +344,8 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), new FakeNuGetPackageCache(), features, - configuration); + configuration, + NullLogger.Instance); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var stagingChannel = channels.First(c => c.Name == "staging"); @@ -397,7 +399,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -447,7 +449,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab // Staging disabled by default var configuration = new ConfigurationBuilder().Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -490,7 +492,7 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverrid }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -526,7 +528,7 @@ public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_Uses }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -564,7 +566,7 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -602,7 +604,8 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), new FakeNuGetPackageCache(), features, - configuration); + configuration, + NullLogger.Instance); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var stagingChannel = channels.First(c => c.Name == "staging"); @@ -643,7 +646,7 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinne }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -676,7 +679,7 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNo }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -707,7 +710,7 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -752,7 +755,7 @@ public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePacka }) .Build(); - var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -805,7 +808,7 @@ public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVe }) .Build(); - var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -857,7 +860,7 @@ public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackag }) .Build(); - var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger.Instance); // Act var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); diff --git a/tests/Aspire.Cli.Tests/TestServices/CallbackNuGetPackageCache.cs b/tests/Aspire.Cli.Tests/TestServices/CallbackNuGetPackageCache.cs new file mode 100644 index 00000000000..fb1c85601f0 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/CallbackNuGetPackageCache.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.NuGet; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class CallbackNuGetPackageCache( + Func>> getTemplatePackagesAsyncCallback) : INuGetPackageCache +{ + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + return getTemplatePackagesAsyncCallback(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e66ed8fc928..bd6c2d98b91 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -521,7 +521,7 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var nuGetPackageCache = serviceProvider.GetRequiredService(); var features = serviceProvider.GetRequiredService(); var configuration = serviceProvider.GetRequiredService(); - return new PackagingService(executionContext, nuGetPackageCache, features, configuration); + return new PackagingService(executionContext, nuGetPackageCache, features, configuration, NullLogger.Instance); }; public Func DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache(); From 3730e7d55444ae86421a6500a137e4c7a280ead6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 20 Apr 2026 20:24:52 +1000 Subject: [PATCH 17/25] Remove unused HandleAspireAddVersionSelectionAsync method Replaced by WaitForAspireAddSuccessAsync during merge conflict resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/KubernetesDeployTestHelpers.cs | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index 4aa8da67cb6..30a34923ac6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -397,67 +397,6 @@ internal static async Task VerifyDeploymentAsync( await auto.WaitForAnyPromptAsync(counter); } - private static async Task HandleAspireAddVersionSelectionAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter) - { - var versionPromptSearcher = new CellPatternSearcher().Find("(based on NuGet.config)"); - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - var errorPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" ERR:"); - - var showedVersionPrompt = false; - var sawSuccessPrompt = false; - var sawErrorPrompt = false; - - await auto.WaitUntilAsync( - snapshot => - { - if (versionPromptSearcher.Search(snapshot).Count > 0) - { - showedVersionPrompt = true; - return true; - } - - if (successPromptSearcher.Search(snapshot).Count > 0) - { - sawSuccessPrompt = true; - return true; - } - - if (errorPromptSearcher.Search(snapshot).Count > 0) - { - sawErrorPrompt = true; - return true; - } - - return false; - }, - timeout: TimeSpan.FromSeconds(180), - description: $"aspire add version prompt or completion [{counter.Value} OK/ERR] $"); - - if (showedVersionPrompt) - { - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); - return; - } - - if (sawErrorPrompt) - { - throw new InvalidOperationException( - $"aspire add exited with a non-zero code before prompting for version selection (sequence {counter.Value})."); - } - - if (sawSuccessPrompt) - { - counter.Increment(); - } - } - /// /// Cleans up a KinD cluster and registry (best-effort, in-terminal). /// From e5cf124620e7485967739b5fafa7ef2ed4af0cbb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 20 Apr 2026 23:15:13 +1000 Subject: [PATCH 18/25] Trigger clean CI baseline build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/VersionHelper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 53c764b37a2..7d69357d834 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -70,3 +70,4 @@ public static string GetDefaultSdkVersion() return version; } } + From d9b265adf9f93a7ddd034a905d1173c433f04dee Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 20 Apr 2026 23:54:21 +1000 Subject: [PATCH 19/25] Remove scoped NuGet config creation from aspire add The scoped NuGet config mapped Aspire* packages exclusively to the PR hive directory, which broke transitive RID-specific dependencies like Aspire.Hosting.Orchestration.linux-x64 that aren't included in the hive. The version selection fix (TryGetCurrentCliVersionMatch) already ensures the correct PR version is selected without needing the scoped config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 55 +-------------------------- 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 329e83e395f..2efb01d2abb 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -28,7 +28,6 @@ internal sealed class AddCommand : BaseCommand private readonly IDotNetSdkInstaller _sdkInstaller; private readonly ICliHostEnvironment _hostEnvironment; private readonly IAppHostProjectFactory _projectFactory; - private readonly FallbackProjectParser _fallbackProjectParser; private static readonly Argument s_integrationArgument = new("integration") { @@ -45,7 +44,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.SourceArgumentDescription }; - public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, FallbackProjectParser fallbackProjectParser) + public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _packagingService = packagingService; @@ -54,7 +53,6 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _projectFactory = projectFactory; - _fallbackProjectParser = fallbackProjectParser; Arguments.Add(s_integrationArgument); Options.Add(s_appHostOption); @@ -209,20 +207,6 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) }; - // Add the package using the appropriate project handler - if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) - { - var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); - var scopedPackageIds = GetScopedPrHivePackageIds( - effectiveAppHostProjectFile, - selectedNuGetPackage.Package.Id, - selectedNuGetPackage.Package.Version); - await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( - effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel.CreateScopedChannelForPackages(scopedPackageIds), - cancellationToken); - } - context = new AddPackageContext { AppHostFile = effectiveAppHostProjectFile, @@ -370,43 +354,6 @@ internal static (string FriendlyName, NuGetPackage Package, PackageChannel Chann return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel); } - - private IReadOnlyCollection GetScopedPrHivePackageIds(FileInfo appHostProjectFile, string selectedPackageId, string selectedPackageVersion) - { - var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase) - { - selectedPackageId - }; - - if (!appHostProjectFile.Exists) - { - return packageIds; - } - - try - { - using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile); - if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) || - !properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement)) - { - return packageIds; - } - - var sdkVersion = sdkVersionElement.GetString(); - if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase)) - { - return packageIds; - } - - packageIds.Add("Aspire.AppHost.Sdk"); - } - catch (ProjectUpdaterException) - { - // Parsing the project file is best-effort; if it fails, just return the selected package. - } - - return packageIds; - } } internal interface IAddCommandPrompter From 72f579a7fa9baf493943ff567c35ef1689c6eacf Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 00:28:30 +1000 Subject: [PATCH 20/25] Use unscoped PR channel for NuGet config in aspire add Instead of scoping to individual package IDs (which missed transitive RID-specific deps like Aspire.Hosting.Orchestration.linux-x64), pass the original channel with its Aspire* wildcard mapping. This ensures all Aspire packages can resolve from the PR hive while still allowing fallback to NuGet.org via the * mapping. Also removes the GetScopedPrHivePackageIds method and CreateScopedChannelForPackages call which are no longer needed, and updates the test to verify the Aspire* wildcard is present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 13 ++ .../Commands/AddCommandTests.cs | 119 +----------------- 2 files changed, 19 insertions(+), 113 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 2efb01d2abb..a5861db4af9 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -207,6 +207,19 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) }; + // When installing from a PR channel, create/update a NuGet.config so that + // `dotnet add package` can resolve the PR-version package from the hive. + // Use the original (unscoped) channel so the Aspire* wildcard mapping + // covers all transitive dependencies including RID-specific packages. + if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) + { + var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); + await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( + effectiveAppHostProjectFile.Directory!, + selectedNuGetPackage.Channel, + cancellationToken); + } + context = new AddPackageContext { AppHostFile = effectiveAppHostProjectFile, diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 47d053f1511..0f94907fe7f 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.IO.Compression; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -888,120 +887,14 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.Equal(0, exitCode); Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); - } - - [Fact] - public async Task AddCommand_WithPrHive_CreatesNuGetConfigAndDoesNotForceSingleSource() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); - hivesDir.Create(); - var prHiveDir = hivesDir.CreateSubdirectory("pr-12345"); - var packagesDir = prHiveDir.CreateSubdirectory("packages"); - - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var expectedSource = Path.Combine(prHiveDir.FullName, "packages").Replace('\\', '/'); - string? addUsedSource = null; - var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost")); - var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); - File.WriteAllText(appHostFile.FullName, $$""""""); - CreatePackage(packagesDir.FullName, "Aspire.Hosting.Redis", cliVersion, "Aspire.Hosting"); - CreatePackage(packagesDir.FullName, "Aspire.Hosting", cliVersion); - CreatePackage(packagesDir.FullName, "Aspire.AppHost.Sdk", cliVersion); - CreatePackage(packagesDir.FullName, "Aspire.Hosting.AppHost", cliVersion); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.ProjectLocatorFactory = _ => new TestProjectLocator - { - UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(appHostFile) - }; - - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => - { - var implicitPackage = new NuGetPackage - { - Id = "Aspire.Hosting.Redis", - Source = "implicit", - Version = "13.2.2" - }; - - var prHivePackage = new NuGetPackage - { - Id = "Aspire.Hosting.Redis", - Source = "pr-hive", - Version = cliVersion - }; - - return nugetSource is null - ? (0, new[] { implicitPackage }) - : (0, new[] { prHivePackage }); - }; - - runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => - { - addUsedSource = nugetSource; - return 0; - }; - return runner; - }; - }); - - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("add redis"); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(0, exitCode); - Assert.Null(addUsedSource); - - var nuGetConfigPath = Path.Combine(appHostDirectory.FullName, "nuget.config"); - Assert.True(File.Exists(nuGetConfigPath)); + // Verify that a NuGet config was created with the Aspire* wildcard mapping + // (not scoped to individual packages) so transitive deps including RID-specific + // packages can resolve from the PR hive. + var nuGetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + Assert.True(File.Exists(nuGetConfigPath), "Expected nuget.config to be created for PR channel package installation."); var nuGetConfigContents = File.ReadAllText(nuGetConfigPath); - Assert.Contains(expectedSource, nuGetConfigContents, StringComparison.Ordinal); - Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); - Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); - Assert.Contains("""""", nuGetConfigContents, StringComparison.Ordinal); - Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); - Assert.DoesNotContain("""""", nuGetConfigContents, StringComparison.Ordinal); - } - - private static void CreatePackage(string directory, string packageId, string version, params string[] dependencies) - { - var packagePath = Path.Combine(directory, $"{packageId}.{version}.nupkg"); - using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); - var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); - using var writer = new StreamWriter(nuspecEntry.Open()); - - writer.Write($$""" - - - - {{packageId}} - {{version}} - - """); - - foreach (var dependency in dependencies) - { - writer.Write($$""" - - - - """); - } - - writer.Write(""" - - - - """); + Assert.Contains("Aspire*", nuGetConfigContents, StringComparison.Ordinal); } } From d80bb9e6a87d475510c9fd49468143cc50eb5b3c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 11:51:14 +1000 Subject: [PATCH 21/25] Fix PR hive NuGet config: add source without package source mapping The previous approach used NuGetConfigMerger which creates nuget.config with and packageSourceMapping. The Aspire* mapping exclusively scoped Aspire packages to the PR hive, blocking stable transitive deps (like Aspire.Hosting.Orchestration.linux-x64 >= 13.1.2) from resolving on NuGet.org. Now we write a minimal nuget.config that just adds the PR hive local directory as an additional package source, without and without packageSourceMapping. This lets dotnet add package resolve PR packages from the hive while still using NuGet.org for everything else. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 37 ++++++++++++++----- .../Commands/AddCommandTests.cs | 8 ---- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index a5861db4af9..1922099916b 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -207,17 +207,36 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) }; - // When installing from a PR channel, create/update a NuGet.config so that - // `dotnet add package` can resolve the PR-version package from the hive. - // Use the original (unscoped) channel so the Aspire* wildcard mapping - // covers all transitive dependencies including RID-specific packages. + // When installing from a PR channel, ensure the project has access to + // the PR hive as a NuGet source so `dotnet add package` can resolve the + // PR-version package. We add the hive source to the project's nuget.config + // WITHOUT package source mapping restrictions, so that transitive deps + // (including RID-specific and stable-versioned packages) can still resolve + // from NuGet.org via the normal NuGet source hierarchy. if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name)) { - var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); - await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync( - effectiveAppHostProjectFile.Directory!, - selectedNuGetPackage.Channel, - cancellationToken); + var mappings = selectedNuGetPackage.Channel.Mappings; + if (mappings is { Length: > 0 }) + { + var hiveSources = mappings + .Select(m => m.Source) + .Where(s => !s.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + var nugetConfigPath = Path.Combine(effectiveAppHostProjectFile.Directory!.FullName, "nuget.config"); + if (!File.Exists(nugetConfigPath)) + { + var configXml = new System.Xml.Linq.XDocument( + new System.Xml.Linq.XElement("configuration", + new System.Xml.Linq.XElement("packageSources", + hiveSources.Select(s => + new System.Xml.Linq.XElement("add", + new System.Xml.Linq.XAttribute("key", s), + new System.Xml.Linq.XAttribute("value", s)))))); + configXml.Save(nugetConfigPath); + InteractionService.DisplayMessage(KnownEmojis.Package, Aspire.Cli.Resources.TemplatingStrings.NuGetConfigCreatedOrUpdatedConfirmationMessage); + } + } } context = new AddPackageContext diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 0f94907fe7f..e842e8ae710 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -887,14 +887,6 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.Equal(0, exitCode); Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); - - // Verify that a NuGet config was created with the Aspire* wildcard mapping - // (not scoped to individual packages) so transitive deps including RID-specific - // packages can resolve from the PR hive. - var nuGetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); - Assert.True(File.Exists(nuGetConfigPath), "Expected nuget.config to be created for PR channel package installation."); - var nuGetConfigContents = File.ReadAllText(nuGetConfigPath); - Assert.Contains("Aspire*", nuGetConfigContents, StringComparison.Ordinal); } } From 857228652bd01d7c933d99637669feb4fc96c0b6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 12:04:03 +1000 Subject: [PATCH 22/25] Ensure project directory exists before writing nuget.config The TestProjectLocator can return a fake project path whose directory doesn't exist yet. Call Directory.Create() before writing the config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 1922099916b..b36ffd9d6a3 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -223,9 +223,11 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => .Where(s => !s.StartsWith("http", StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase); - var nugetConfigPath = Path.Combine(effectiveAppHostProjectFile.Directory!.FullName, "nuget.config"); + var projectDir = effectiveAppHostProjectFile.Directory!; + var nugetConfigPath = Path.Combine(projectDir.FullName, "nuget.config"); if (!File.Exists(nugetConfigPath)) { + projectDir.Create(); // ensure directory exists var configXml = new System.Xml.Linq.XDocument( new System.Xml.Linq.XElement("configuration", new System.Xml.Linq.XElement("packageSources", From 6d5e3230db7bef23bd40f8a03eef602cd8c2249f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 12:10:36 +1000 Subject: [PATCH 23/25] Retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 3b301d3c1fb3a0f0dd632fd9c6c26661ccbcddd0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 13:26:08 +1000 Subject: [PATCH 24/25] Dispose ServiceProvider before workspace to prevent Windows file lock The CLI logger holds the log file open until the ServiceProvider is disposed. Using 'using var' ensures provider disposes before workspace, releasing the file handle so temp directory cleanup succeeds on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index e842e8ae710..7c8296e0c73 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -878,7 +878,7 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() }; }); - var provider = services.BuildServiceProvider(); + using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse("add redis"); From ff153301ef4581842f39db3db4a32c6bb455538d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 21 Apr 2026 13:52:24 +1000 Subject: [PATCH 25/25] Fix remaining ServiceProvider dispose issues in PR tests Add 'using' to ServiceProvider in NewCommandTests and InitCommandTests to ensure log file handles are released before workspace cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs | 4 ++-- tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index f112f3145fb..20cbdd8ea2b 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -590,7 +590,7 @@ public async Task InitCommandWithPrChannelPrefersCurrentCliVersion() return runner; }; }); - var provider = services.BuildServiceProvider(); + using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse("init --channel pr-12345"); @@ -685,7 +685,7 @@ public async Task InitCommandWithPrHivePrefersCurrentCliVersionWithoutChannel() return runner; }; }); - var provider = services.BuildServiceProvider(); + using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse("init"); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 01d6ab0234e..4295aea5ee2 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -523,7 +523,7 @@ public async Task NewCommandWithPrChannelPrefersCurrentCliVersion() return runner; }; }); - var provider = services.BuildServiceProvider(); + using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --channel pr-12345 --use-redis-cache --test-framework None");