From 7aea1cf649a6f71599d41e3d9c5b77844b234d4a Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 27 May 2026 15:51:40 -0700 Subject: [PATCH 1/2] Prefer current CLI template version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackageChannel.cs | 5 +- src/Aspire.Cli/Utils/VersionHelper.cs | 4 +- .../NewCommandChannelResolutionTests.cs | 39 +++++++++-- .../Utils/VersionHelperTests.cs | 65 +++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 330821ff384..c619b2f7afb 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -85,12 +85,15 @@ public async Task> GetTemplatePackagesAsync(DirectoryI .DistinctBy(p => $"{p.Id}-{p.Version}"); // When doing a `dotnet package search` the results may include stable packages even when searching for - // prerelease packages. This filters out this noise. + // prerelease packages. Keep the current CLI/SDK version so shipped CLIs can resolve their + // matching template package from daily/staging feeds, then filter out the remaining noise. + var currentCliVersion = VersionHelper.GetDefaultSdkVersion(); var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch { { Quality: PackageChannelQuality.Both } => true, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, + { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: false } } when string.Equals(p.Version, currentCliVersion, StringComparison.OrdinalIgnoreCase) => true, _ => false }); diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index dfb85cb73eb..02d3f78e6ea 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -24,7 +24,7 @@ public static bool IsLocalBuildChannel(string? channelName) } /// - /// Finds the candidate that exactly matches the current CLI/SDK version when running against local build channels or hives. + /// Finds the candidate that exactly matches the current CLI/SDK version when a channel has already been selected or local hives are present. /// public static bool TryGetCurrentCliVersionMatch( IEnumerable candidates, @@ -36,7 +36,7 @@ public static bool TryGetCurrentCliVersionMatch( ArgumentNullException.ThrowIfNull(candidates); ArgumentNullException.ThrowIfNull(versionSelector); - if (!hasPrHives && !IsLocalBuildChannel(channelName)) + if (!hasPrHives && string.IsNullOrWhiteSpace(channelName)) { match = default; return false; diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 8288dc321c1..ceb22892ba2 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Templating; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using NuGetPackage = Aspire.Shared.NuGetPackageCli; @@ -211,6 +212,27 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() Assert.Equal(PackageChannelNames.Stable, captured.Channel); } + /// + /// A shipped CLI must prefer its own SDK/template version from an explicitly selected + /// non-local channel instead of floating to a newer daily/staging package from the same feed. + /// + [Theory] + [InlineData(PackageChannelNames.Daily)] + [InlineData(PackageChannelNames.Staging)] + public async Task NewCommand_ExplicitPrereleaseChannel_PrefersCurrentCliVersionWhenAvailable(string channelName) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + + var captured = await CaptureTemplateInputsAsync( + identityChannel: channelName, + channelOptionArg: channelName, + identityChannelVersion: cliVersion, + identityChannelVersions: ["99.0.0-preview.1", cliVersion]); + + Assert.Equal(cliVersion, captured.Version); + Assert.Equal(channelName, captured.Channel); + } + /// /// Invokes with a fake CLI-runtime template that captures the /// handed to it. This is the contract surface the four @@ -228,7 +250,8 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() private async Task CaptureTemplateInputsAsync( string identityChannel, string? channelOptionArg, - string? identityChannelVersion) + string? identityChannelVersion, + IEnumerable? identityChannelVersions = null) { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -260,7 +283,7 @@ private async Task CaptureTemplateInputsAsync( options.TemplateProviderFactory = _ => new SingleTemplateProvider(fakeTemplate); - options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion); + options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion, identityChannelVersions); }); using var serviceProvider = services.BuildServiceProvider(); @@ -280,8 +303,14 @@ private async Task CaptureTemplateInputsAsync( /// pr-* explicit channels), but with deterministic per-channel template versions so /// tests can identify which channel won resolution. /// - private static IPackagingService BuildPackagingService(string identityChannel, string? identityChannelVersion) + private static IPackagingService BuildPackagingService( + string identityChannel, + string? identityChannelVersion, + IEnumerable? identityChannelVersions) { + var identityVersions = identityChannelVersions?.ToArray() + ?? (identityChannelVersion is null ? [] : [identityChannelVersion]); + // Implicit channel always returns the stable token so a "fell-through to Implicit" // outcome is distinguishable from an identity-channel pickup. var implicitCache = new FakeNuGetPackageCache @@ -313,7 +342,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. // Register a non-stable explicit channel matching the identity, when the test // scenario calls for it. Deliberately omitted in the "identity not registered" // case so fallback to Implicit can be observed. - var isDailyOrStaging = identityChannelVersion is not null && + var isDailyOrStaging = identityVersions.Length > 0 && !string.Equals(identityChannel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase) && !identityChannel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase); if (isDailyOrStaging) @@ -322,7 +351,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. { GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult>( - [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = identityChannelVersion! }]) + identityVersions.Select(version => new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = version })) }; channels.Add(PackageChannel.CreateExplicitChannel( identityChannel, diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs index ef217314d55..534de25d5fd 100644 --- a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -28,6 +28,71 @@ public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentC Assert.Equal(cliVersion, match); } + [Theory] + [InlineData("daily")] + [InlineData("staging")] + [InlineData("stable")] + public void TryGetCurrentCliVersionMatch_WithNamedChannel_ReturnsCurrentCliVersion(string channelName) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: channelName, + hasPrHives: false); + + Assert.True(result); + Assert.Equal(cliVersion, match); + } + + [Fact] + public void TryGetCurrentCliVersionMatch_WithNamedChannelAndNoExactMatch_ReturnsFalse() + { + var candidates = new[] + { + "99.0.0", + "98.0.0", + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: "daily", + hasPrHives: false); + + Assert.False(result); + Assert.Null(match); + } + + [Fact] + public void TryGetCurrentCliVersionMatch_WithNoChannelAndNoPrHives_ReturnsFalse() + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: null, + hasPrHives: false); + + Assert.False(result); + Assert.Null(match); + } + [Theory] [InlineData("pr-16820", true)] [InlineData("run-25422767716", true)] From 1d4ea39d8f955aff1d247fc048db0398d43c5062 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 27 May 2026 16:02:04 -0700 Subject: [PATCH 2/2] Pin aspire new template version to CLI Ensure CLI-runtime templates selected from explicit package channels use the current bundled CLI/SDK version instead of floating to newer channel packages that can mismatch the bundled AppHost server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 37 +++++++++++++++---- .../NewCommandChannelResolutionTests.cs | 34 +++++++++++------ .../Commands/NewCommandTests.cs | 4 +- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index f7bbfeeb0cc..d5050c5b3b4 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -177,6 +177,34 @@ private async Task PromptForAppHostLanguageAsync(IReadOnlyList s return selected.LanguageId; } + private static NuGetPackage? TryGetCurrentCliTemplateVersionPackage(PackageChannel selectedChannel, NuGetPackage[] packages, bool hasPrHives) + { + if (VersionHelper.TryGetCurrentCliVersionMatch( + packages, + p => p.Version, + out var cliVersionPackage, + channelName: selectedChannel.Name, + hasPrHives: hasPrHives)) + { + return cliVersionPackage; + } + + if (packages.Length > 0 && + selectedChannel.Type is PackageChannelType.Explicit && + !VersionHelper.IsLocalBuildChannel(selectedChannel.Name)) + { + // Prerelease channels can filter out the shipped stable package even when the feed can restore it. + return new NuGetPackage + { + Id = TemplateNuGetConfigService.TemplatesPackageName, + Version = VersionHelper.GetDefaultSdkVersion(), + Source = selectedChannel.SourceDetails + }; + } + + return null; + } + private async Task<(bool Success, string? LanguageId)> ResolveSelectedLanguageAsync(ITemplate template, ParseResult parseResult, CancellationToken cancellationToken) { var explicitLanguageId = ParseExplicitLanguageId(parseResult); @@ -379,14 +407,7 @@ private async Task ResolveCliTemplateVersionAsync( .ToArray(); var hasPrHives = ExecutionContext.GetHiveCount() > 0; - NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch( - packages, - p => p.Version, - out var cliVersionPackage, - channelName: selectedChannel.Name, - hasPrHives: hasPrHives) - ? cliVersionPackage - : null; + var package = TryGetCurrentCliTemplateVersionPackage(selectedChannel, packages, hasPrHives); package ??= packages .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index ceb22892ba2..521b8815500 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -116,12 +116,9 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK /// /// Channel-resolution contract: when the running CLI's identity is a non-local channel /// (daily / staging / stable) and no --channel is passed, aspire new must - /// resolve the template version from the channel whose name matches the identity — not - /// from the Implicit (nuget.org) channel. Without this, a daily/staging CLI silently - /// resolves a stable nuget.org template while the per-project channel pin (written by - /// the template factories) still points at the channel-specific feed — yielding an - /// inconsistent project that aspire restore rejects with "Unable to find a stable - /// package". + /// resolve the channel whose name matches the identity — not the Implicit (nuget.org) + /// channel — while still pinning the template version to the current CLI/SDK version. + /// The bundled server and restored Aspire packages must stay on the same version. /// [Theory] [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] @@ -133,10 +130,22 @@ public async Task NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel(st channelOptionArg: null, identityChannelVersion: identityChannelVersion); - Assert.Equal(identityChannelVersion, captured.Version); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(identityChannel, captured.Channel); } + [Fact] + public async Task NewCommand_NoChannelArg_DailyChannelWithoutExactCliVersion_PinsTemplateToCurrentCliVersion() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: null, + identityChannelVersion: "13.5.0-preview.1.99999.1"); + + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); + Assert.Equal(PackageChannelNames.Daily, captured.Channel); + } + /// /// PR-channel CLI is already covered by the local-build channel branch retained in /// . Pinned here so a future refactor doesn't regress the @@ -180,8 +189,8 @@ public async Task NewCommand_NoChannelArg_IdentityChannelNotRegistered_FallsBack /// /// Issue #17121 regression guard: a staging-identity CLI should have a registered /// staging channel from PackagingService.GetChannelsAsync, so aspire new - /// resolves templates from staging instead of falling back to the Implicit NuGet.org - /// channel. + /// resolves the channel from staging instead of falling back to the Implicit NuGet.org + /// channel, while keeping the template version pinned to the current CLI. /// [Fact] public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegistered_ResolvesTemplateFromStaging() @@ -191,14 +200,15 @@ public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegis channelOptionArg: null, identityChannelVersion: "13.4.0-rc.1.99999.1"); - Assert.Equal("13.4.0-rc.1.99999.1", captured.Version); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(PackageChannelNames.Staging, captured.Channel); } /// /// Explicit --channel must always override the running CLI's identity channel — /// so a developer on a daily CLI can still scaffold a stable-channel project for - /// reproduction or migration testing. + /// reproduction or migration testing. The template version still stays pinned to the + /// current CLI so restored Aspire packages match the bundled server. /// [Fact] public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() @@ -208,7 +218,7 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() channelOptionArg: PackageChannelNames.Stable, identityChannelVersion: "13.4.0-preview.1.99999.1"); - Assert.Equal("13.5.0", captured.Version); // stable channel version + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(PackageChannelNames.Stable, captured.Channel); } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index e70b0272969..03632c295c2 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1609,7 +1609,7 @@ public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndC var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal("9.2.0", scaffoldSdkVersion); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), scaffoldSdkVersion); Assert.Equal("stable", scaffoldChannel); } @@ -1810,7 +1810,7 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() Assert.Equal(CliExitCodes.Success, exitCode); Assert.True(buildAndGenerateCalled); Assert.Equal("daily", channelSeenByProject); - Assert.Equal("9.2.0", sdkVersionSeenByProject); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), sdkVersionSeenByProject); Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", LanguageInfo.GeneratedFolderName, "aspire.mts"))); }