Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,34 @@ private async Task<string> PromptForAppHostLanguageAsync(IReadOnlyList<string> 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);
Expand Down Expand Up @@ -379,14 +407,7 @@ private async Task<ResolveTemplateVersionResult> 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)
Expand Down
5 changes: 4 additions & 1 deletion src/Aspire.Cli/Packaging/PackageChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ public async Task<IEnumerable<NuGetPackage>> 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
});

Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Utils/VersionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public static bool IsLocalBuildChannel(string? channelName)
}

/// <summary>
/// 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.
/// </summary>
public static bool TryGetCurrentCliVersionMatch<T>(
IEnumerable<T> candidates,
Expand All @@ -36,7 +36,7 @@ public static bool TryGetCurrentCliVersionMatch<T>(
ArgumentNullException.ThrowIfNull(candidates);
ArgumentNullException.ThrowIfNull(versionSelector);

if (!hasPrHives && !IsLocalBuildChannel(channelName))
if (!hasPrHives && string.IsNullOrWhiteSpace(channelName))
{
match = default;
return false;
Expand Down
73 changes: 56 additions & 17 deletions tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,12 +116,9 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK
/// <summary>
/// Channel-resolution contract: when the running CLI's identity is a non-local channel
/// (daily / staging / stable) and no <c>--channel</c> is passed, <c>aspire new</c> 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 <c>aspire restore</c> 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.
/// </summary>
[Theory]
[InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")]
Expand All @@ -132,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);
}

/// <summary>
/// PR-channel CLI is already covered by the local-build channel branch retained in
/// <see cref="NewCommand"/>. Pinned here so a future refactor doesn't regress the
Expand Down Expand Up @@ -179,8 +189,8 @@ public async Task NewCommand_NoChannelArg_IdentityChannelNotRegistered_FallsBack
/// <summary>
/// Issue #17121 regression guard: a staging-identity CLI should have a registered
/// staging channel from <c>PackagingService.GetChannelsAsync</c>, so <c>aspire new</c>
/// 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.
/// </summary>
[Fact]
public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegistered_ResolvesTemplateFromStaging()
Expand All @@ -190,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);
}

/// <summary>
/// Explicit <c>--channel</c> 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.
/// </summary>
[Fact]
public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel()
Expand All @@ -207,10 +218,31 @@ 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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// Invokes <see cref="NewCommand"/> with a fake CLI-runtime template that captures the
/// <see cref="TemplateInputs"/> handed to it. This is the contract surface the four
Expand All @@ -228,7 +260,8 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel()
private async Task<CapturedTemplateInputs> CaptureTemplateInputsAsync(
string identityChannel,
string? channelOptionArg,
string? identityChannelVersion)
string? identityChannelVersion,
IEnumerable<string>? identityChannelVersions = null)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);

Expand Down Expand Up @@ -260,7 +293,7 @@ private async Task<CapturedTemplateInputs> CaptureTemplateInputsAsync(

options.TemplateProviderFactory = _ => new SingleTemplateProvider(fakeTemplate);

options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion);
options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion, identityChannelVersions);
});

using var serviceProvider = services.BuildServiceProvider();
Expand All @@ -280,8 +313,14 @@ private async Task<CapturedTemplateInputs> CaptureTemplateInputsAsync(
/// pr-* explicit channels), but with deterministic per-channel template versions so
/// tests can identify which channel won resolution.
/// </summary>
private static IPackagingService BuildPackagingService(string identityChannel, string? identityChannelVersion)
private static IPackagingService BuildPackagingService(
string identityChannel,
string? identityChannelVersion,
IEnumerable<string>? 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
Expand Down Expand Up @@ -313,7 +352,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)
Expand All @@ -322,7 +361,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[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,
Expand Down
4 changes: 2 additions & 2 deletions tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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")));
}

Expand Down
65 changes: 65 additions & 0 deletions tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading