From 85b2b8e3b2097eb09b5596ff775904aba4055104 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 10:13:41 +1000 Subject: [PATCH 1/4] Add failing tests for aspire-starter channel resolution on release/13.4 Reproduces the bug where 'aspire new aspire-starter' (and aspire-starter-csharp-typescript) silently resolve Aspire.ProjectTemplates from the Implicit (nuget.org) channel on a daily / staging / release-branch CLI, ignoring CliExecutionContext.IdentityChannel. Passing --channel works because that value is the only thing forwarded into TemplateInputs.Channel today. The new tests pin the contract for both DotNet- and CLI-runtime templates: NewCommand must forward IdentityChannel into inputs.Channel whenever --channel is not passed, so DotNetTemplateFactory.ApplyTemplateAsync uses the identity-matching channel when calling TemplateNuGetConfigService.ResolveTemplatePackageAsync. Coverage across the four shipping identities (pr-, daily, staging, stable) plus three edge cases (unregistered identity falls back to null, explicit --channel overrides identity, --version still forwards identity into inputs.Channel). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NewCommandChannelResolutionTests.cs | 152 ++++++++++++++++-- 1 file changed, 137 insertions(+), 15 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 25243715c70..80f81fa7e47 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -272,12 +272,121 @@ public async Task NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBack } /// - /// Invokes with a fake CLI-runtime template that captures the - /// handed to it. This is the contract surface the four - /// shipping CLI templates (TS/Python/Go starter + empty AppHost) all read from. Its - /// Version reflects which channel won template-version resolution; its - /// Channel reflects what the template factories will persist into the new - /// project's aspire.config.json. + /// Issue: aspire new aspire-starter (and aspire-starter-csharp-typescript) + /// run through the path, which delegates template + /// package resolution to TemplateNuGetConfigService.ResolveTemplatePackageAsync + /// using TemplateInputs.Channel as the RequestedChannel. When no + /// --channel is supplied, must forward the running CLI's + /// through inputs.Channel so the + /// DotNet template path searches the identity-matching feed for + /// Aspire.ProjectTemplates — symmetrical to the CLI-runtime path + /// (). + /// + /// Without this forwarding, a daily / staging / release-branch CLI silently resolves the + /// templates package from the Implicit (nuget.org) channel — for example, on a 13.4 + /// CLI it fails to discover the matching 13.4.0 template and falls back to the + /// latest stable on nuget.org. Passing --channel explicitly works because that + /// value is already forwarded into inputs.Channel. + /// + /// + /// All four shipping identity shapes are exercised — PR (developer dogfood build + /// against a hive), daily (nightly dnceng feed), staging (release-branch dnceng feed), + /// and stable (nuget.org via the explicit Stable channel registration). + /// + /// + [Theory] + [InlineData("pr-99999", "13.4.0-pr.99999.gabc123")] + [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] + [InlineData(PackageChannelNames.Staging, "13.4.0-rc.1.99999.1")] + [InlineData(PackageChannelNames.Stable, "13.5.0")] + public async Task NewCommand_DotNetRuntimeTemplate_NoChannelArg_ForwardsIdentityChannelToInputs(string identityChannel, string identityChannelVersion) + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: identityChannel, + channelOptionArg: null, + identityChannelVersion: identityChannelVersion, + runtime: TemplateRuntime.DotNet); + + // DotNet-runtime templates resolve the template package version themselves inside + // DotNetTemplateFactory.ApplyTemplateAsync — NewCommand does not populate inputs.Version + // for this runtime — so only inputs.Channel is asserted here. + Assert.Equal(identityChannel, captured.Channel); + } + + /// + /// Defensive: when the identity channel is something that isn't a registered channel + /// (typo, future addition, locally-built CLI without the local hive installed, etc.), + /// must NOT blindly forward the unrecognized identity into + /// inputs.Channel — doing so would make the DotNet template path throw + /// ChannelNotFoundException inside TemplateNuGetConfigService.ResolveTemplatePackageAsync. + /// Instead, leave inputs.Channel as null so the resolver consults the Implicit + /// (nuget.org) channel and the new project inherits the user's ambient NuGet configuration. + /// Mirrors the CLI-runtime contract pinned by + /// . + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_NoChannelArg_IdentityChannelNotRegistered_FallsBackToNull() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: "stalbe", // intentional typo: not registered as a channel + channelOptionArg: null, + identityChannelVersion: null, + runtime: TemplateRuntime.DotNet); + + // Implicit-fallback case: inputs.Channel stays null so DotNetTemplateFactory does not + // pin a per-project channel and the new project uses the ambient NuGet configuration. + Assert.Null(captured.Channel); + } + + /// + /// Explicit --channel on a DotNet-runtime template (aspire-starter family) must + /// still flow through inputs.Channel verbatim and override the running CLI's + /// identity. Pinned here so the identity-channel forwarding fix doesn't accidentally + /// clobber an explicit user choice (e.g. a daily CLI scaffolding a stable-channel + /// project for migration testing). + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_ExplicitChannelArg_OverridesIdentityChannel() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: PackageChannelNames.Stable, + identityChannelVersion: "13.4.0-preview.1.99999.1", + runtime: TemplateRuntime.DotNet); + + Assert.Equal(PackageChannelNames.Stable, captured.Channel); + } + + /// + /// --version on a DotNet-runtime template must still flow through to + /// inputs.Version AND identity-channel forwarding must still populate + /// inputs.Channel. The version pin tells ResolveTemplatePackageAsync which + /// package to pick, but the channel pin still selects the feed that package is fetched + /// from and the per-project NuGet.config mappings the generated project will use. + /// Without both, a daily CLI passing --version 13.4.0-preview.1.99999.1 would + /// resolve the version against nuget.org (where prerelease daily builds aren't published) + /// and fail. + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_VersionOverride_StillForwardsIdentityChannel() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: null, + identityChannelVersion: "13.4.0-preview.1.99999.1", + runtime: TemplateRuntime.DotNet, + versionOptionArg: "13.4.0-preview.1.99999.1"); + + Assert.Equal("13.4.0-preview.1.99999.1", captured.Version); + Assert.Equal(PackageChannelNames.Daily, captured.Channel); + } + + /// + /// Invokes with a fake template that captures the + /// handed to it. Its Version reflects which channel + /// won template-version resolution; its Channel reflects what the template + /// factories will persist into the new project's aspire.config.json (or use as + /// the RequestedChannel for DotNet-runtime templates). /// /// Identity baked into the CLI under test. /// Value passed via --channel, or null to omit the flag. @@ -285,23 +394,35 @@ public async Task NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBack /// Version returned by the channel whose name matches , /// or null when that channel is not registered. /// + /// Optional list of versions exposed by the identity channel. + /// Template runtime kind. CLI runtime drives ResolveCliTemplateVersionAsync; DotNet runtime mirrors aspire-starter. private async Task CaptureTemplateInputsAsync( string identityChannel, string? channelOptionArg, string? identityChannelVersion, - IEnumerable? identityChannelVersions = null) + IEnumerable? identityChannelVersions = null, + TemplateRuntime runtime = TemplateRuntime.Cli, + string? versionOptionArg = null) { using var workspace = TemporaryWorkspace.Create(outputHelper); var capturedInputs = new CapturedTemplateInputs(); - // A fake CLI-runtime template that intercepts the inputs and returns success - // without invoking the heavyweight template scaffolding pipeline (RPC, codegen, - // bundled NuGet restore). The template is registered via a fake ITemplateProvider - // injected through CliServiceCollectionTestOptions.TemplateProviderFactory. + // A fake template that intercepts the inputs and returns success without invoking + // the heavyweight template scaffolding pipeline (RPC, codegen, bundled NuGet restore). + // The template is registered via a fake ITemplateProvider injected through + // CliServiceCollectionTestOptions.TemplateProviderFactory. + // + // The runtime kind switches which code path inside NewCommand.ExecuteAsync produces + // inputs.Channel: TemplateRuntime.Cli walks ResolveCliTemplateVersionAsync (which + // owns the identity-channel fallback for the CLI starters), while TemplateRuntime.DotNet + // mirrors the path used by aspire-starter / aspire-starter-csharp-typescript, which + // delegate version/feed selection to DotNetTemplateFactory → TemplateNuGetConfigService. + // NewCommand is responsible for passing the right channel into inputs.Channel in both + // cases, so this helper can exercise both with a single shape. var fakeTemplate = new CallbackTemplate( - name: "fake-cli-template", - description: "Fake CLI-runtime template for channel-resolution tests", + name: "fake-template", + description: "Fake template for channel-resolution tests", pathDeriverCallback: (ctx, projectName) => Path.Combine(ctx.WorkingDirectory.FullName, projectName), applyOptionsCallback: _ => { }, applyTemplateCallback: (_, inputs, _, _) => @@ -312,7 +433,7 @@ private async Task CaptureTemplateInputsAsync( Directory.CreateDirectory(outputPath); return Task.FromResult(new TemplateResult(CliExitCodes.Success, outputPath)); }, - runtime: TemplateRuntime.Cli, + runtime: runtime, languageId: KnownLanguageId.TypeScript); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -328,7 +449,8 @@ private async Task CaptureTemplateInputsAsync( var newCommand = serviceProvider.GetRequiredService(); var channelArg = string.IsNullOrEmpty(channelOptionArg) ? "" : $" --channel {channelOptionArg}"; - var parseResult = newCommand.Parse($"new fake-cli-template --name TestApp --output ./captured{channelArg}"); + var versionArg = string.IsNullOrEmpty(versionOptionArg) ? "" : $" --version {versionOptionArg}"; + var parseResult = newCommand.Parse($"new fake-template --name TestApp --output ./captured{channelArg}{versionArg}"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); From 40dffb1fdeb41c7bc21790672b72d2fafca64fcd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 10:18:10 +1000 Subject: [PATCH 2/4] Forward IdentityChannel to TemplateInputs.Channel for dotnet-runtime templates When 'aspire new' invoked a TemplateRuntime.DotNet template (e.g. aspire-starter, aspire-starter-csharp-typescript) without an explicit --channel argument, inputs.Channel was left null. That caused TemplateNuGetConfigService.ResolveTemplatePackageAsync to fall back to the Implicit (nuget.org) channel, ignoring the CLI's IdentityChannel. Daily / staging / pr builds would therefore resolve Aspire.ProjectTemplates from nuget.org instead of the channel matching the running CLI. This change mirrors the existing CLI-runtime contract: when --channel is absent and no version-resolution channel was selected, look up the identity channel in the packaging service and forward it to inputs.Channel only when it matches a registered Explicit channel (Implicit matches still resolve to null so the project inherits ambient NuGet configuration). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 50 ++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index be7de6bab00..ecedc5f0cc3 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -489,13 +489,34 @@ protected override async Task ExecuteAsync(ParseResult parseResul resolvedChannelName = resolveResult.ChannelName; } + // For template paths that don't run ResolveCliTemplateVersionAsync — chiefly the + // TemplateRuntime.DotNet starters like `aspire-starter` and + // `aspire-starter-csharp-typescript`, which delegate channel/version resolution to + // DotNetTemplateFactory → TemplateNuGetConfigService.ResolveTemplatePackageAsync — + // we still need to forward the running CLI's IdentityChannel into inputs.Channel + // when `--channel` was not supplied. Without this, the DotNet path searches only + // the Implicit (nuget.org) channel for Aspire.ProjectTemplates regardless of CLI + // identity, so a daily / staging / release-branch CLI silently resolves a stable + // template package (or fails to find a matching release-branch template at all). + // + // The CLI-runtime path resolves its own channel inside ResolveCliTemplateVersionAsync + // and surfaces it via resolveResult.ChannelName, so this fallback only fires when + // resolveResult didn't already pin a channel (i.e. DotNet-runtime templates, or a + // CLI-runtime template invoked with --version which short-circuits the resolver). + var explicitChannelArg = parseResult.GetValue(_channelOption); + string? identityChannelName = null; + if (string.IsNullOrWhiteSpace(explicitChannelArg) && resolvedChannelName is null) + { + identityChannelName = await ResolveIdentityChannelNameAsync(cancellationToken); + } + var inputs = new TemplateInputs { Name = parseResult.GetValue(s_nameOption), Output = parseResult.GetValue(s_outputOption), Source = source, Version = version, - Channel = parseResult.GetValue(_channelOption) ?? resolvedChannelName, + Channel = explicitChannelArg ?? resolvedChannelName ?? identityChannelName, Language = selectedLanguageId }; var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken); @@ -517,6 +538,33 @@ private static bool ShouldResolveCliTemplateVersion(ITemplate template) return template.Runtime is TemplateRuntime.Cli; } + /// + /// Resolves to a registered channel name + /// from the packaging service. Returns the channel name when an Explicit channel matches the + /// identity (e.g. daily, staging, stable, pr-<N>); returns + /// when there is no identity, when no Explicit channel matches, or + /// when only the Implicit (nuget.org) channel is registered. A result + /// intentionally lets the downstream template path consult the Implicit channel and avoids + /// writing a per-project channel pin into the new project's NuGet configuration. + /// + private async Task ResolveIdentityChannelNameAsync(CancellationToken cancellationToken) + { + var identity = ExecutionContext.IdentityChannel; + if (string.IsNullOrWhiteSpace(identity)) + { + return null; + } + + var channels = await _packagingService.GetChannelsAsync(cancellationToken, identity); + var match = channels.FirstOrDefault(c => + string.Equals(c.Name, identity, StringComparisons.ChannelName)); + + // Only persist Explicit channel names — Implicit channels (the nuget.org fallback) + // are deliberately left unpinned so `aspire add` and later restores use ambient + // NuGet configuration. Mirrors the same rule applied at the end of + // ResolveCliTemplateVersionAsync. + return match is { Type: PackageChannelType.Explicit } ? match.Name : null; + } } internal interface INewCommandPrompter From 2e03646118f3e7fde60a5fdbeed413e4962dc983 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 10:24:03 +1000 Subject: [PATCH 3/4] Consolidate channel precedence into a single resolvedChannelName Address review feedback: collapse the explicitChannelArg / resolvedChannelName / identityChannelName three-way coalesce on TemplateInputs.Channel into a single resolvedChannelName variable with a clear precedence chain (explicit --channel > resolver result > identity). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 34 ++++++++++----------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index ecedc5f0cc3..7021bd8e2d4 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -475,6 +475,15 @@ protected override async Task ExecuteAsync(ParseResult parseResul } var version = parseResult.GetValue(s_versionOption); + // Precedence for the channel written into TemplateInputs.Channel: + // 1. Explicit --channel argument (user override always wins). + // 2. Channel returned by ResolveCliTemplateVersionAsync (CLI-runtime templates). + // 3. The running CLI's IdentityChannel, when it matches a registered Explicit + // channel — needed for TemplateRuntime.DotNet starters (aspire-starter, + // aspire-starter-csharp-typescript) which otherwise resolve + // Aspire.ProjectTemplates from the Implicit (nuget.org) channel regardless + // of CLI identity, and also for CLI-runtime templates invoked with --version + // which short-circuits the resolver below. string? resolvedChannelName = null; if (ShouldResolveCliTemplateVersion(template) && string.IsNullOrWhiteSpace(version)) @@ -489,26 +498,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul resolvedChannelName = resolveResult.ChannelName; } - // For template paths that don't run ResolveCliTemplateVersionAsync — chiefly the - // TemplateRuntime.DotNet starters like `aspire-starter` and - // `aspire-starter-csharp-typescript`, which delegate channel/version resolution to - // DotNetTemplateFactory → TemplateNuGetConfigService.ResolveTemplatePackageAsync — - // we still need to forward the running CLI's IdentityChannel into inputs.Channel - // when `--channel` was not supplied. Without this, the DotNet path searches only - // the Implicit (nuget.org) channel for Aspire.ProjectTemplates regardless of CLI - // identity, so a daily / staging / release-branch CLI silently resolves a stable - // template package (or fails to find a matching release-branch template at all). - // - // The CLI-runtime path resolves its own channel inside ResolveCliTemplateVersionAsync - // and surfaces it via resolveResult.ChannelName, so this fallback only fires when - // resolveResult didn't already pin a channel (i.e. DotNet-runtime templates, or a - // CLI-runtime template invoked with --version which short-circuits the resolver). - var explicitChannelArg = parseResult.GetValue(_channelOption); - string? identityChannelName = null; - if (string.IsNullOrWhiteSpace(explicitChannelArg) && resolvedChannelName is null) - { - identityChannelName = await ResolveIdentityChannelNameAsync(cancellationToken); - } + resolvedChannelName = parseResult.GetValue(_channelOption) + ?? resolvedChannelName + ?? await ResolveIdentityChannelNameAsync(cancellationToken); var inputs = new TemplateInputs { @@ -516,7 +508,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul Output = parseResult.GetValue(s_outputOption), Source = source, Version = version, - Channel = explicitChannelArg ?? resolvedChannelName ?? identityChannelName, + Channel = resolvedChannelName, Language = selectedLanguageId }; var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken); From 96acea44ea9e2f2895ad8ba8d163a4dd936d4a58 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 10:32:36 +1000 Subject: [PATCH 4/4] Explain why identity-channel fallback lives at the call site Document why ResolveIdentityChannelNameAsync is invoked at the TemplateInputs assembly site rather than folded into ResolveCliTemplateVersionAsync: the two paths that need the identity hint (TemplateRuntime.DotNet templates and --version short-circuit) are precisely the ones the resolver never runs on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 7021bd8e2d4..92e79ba658d 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -498,6 +498,19 @@ protected override async Task ExecuteAsync(ParseResult parseResul resolvedChannelName = resolveResult.ChannelName; } + // Apply the channel precedence as a single coalesce. The identity fallback lives + // here, not inside ResolveCliTemplateVersionAsync, because that resolver only runs + // on the CLI-runtime / no-explicit-version branch above. The two paths that need + // the identity hint are precisely the ones the resolver does NOT visit: + // * TemplateRuntime.DotNet templates (aspire-starter family) — the bug this fix + // addresses; without forwarding, DotNetTemplateFactory searches only the + // Implicit (nuget.org) channel regardless of CLI identity. + // * CLI-runtime templates invoked with --version, which short-circuits the + // resolver and would otherwise leave inputs.Channel null. + // Keeping the fallback out of the resolver also keeps the resolver's role narrow: + // it performs version negotiation across channels and reports the channel that won; + // the identity hint is a different policy ("label the project with the CLI's own + // channel") that should not influence version selection. resolvedChannelName = parseResult.GetValue(_channelOption) ?? resolvedChannelName ?? await ResolveIdentityChannelNameAsync(cancellationToken);