Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ internal sealed class IntegrationPackageSearchService(

public async Task<IEnumerable<(NuGetPackage Package, PackageChannel Channel)>> GetIntegrationPackagesWithChannelsAsync(DirectoryInfo workingDirectory, string? configuredChannel, CancellationToken cancellationToken)
{
var allChannels = await packagingService.GetChannelsAsync(cancellationToken);
var allChannels = await packagingService.GetChannelsAsync(cancellationToken, configuredChannel);

if (!string.IsNullOrEmpty(configuredChannel))
{
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ public NewCommand(
Options.Add(s_suppressAgentInitOption);

// Customize description based on whether staging channel is enabled
var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, configuration);
var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, configuration)
|| string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase);
_channelOption = new Option<string?>("--channel")
{
Description = isStagingEnabled
Expand Down Expand Up @@ -309,9 +310,8 @@ private async Task<ResolveTemplateVersionResult> ResolveCliTemplateVersionAsync(
NewCommandStrings.ResolvingTemplateVersion,
async () =>
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken);

var configuredChannelName = parseResult.GetValue(_channelOption);
var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName);

// When no --channel was passed, prefer the channel whose name matches the running
// CLI's identity (CliExecutionContext.IdentityChannel — stable, staging, daily,
Expand Down
12 changes: 9 additions & 3 deletions src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public UpdateCommand(
AddNonInteractiveRequiresYesValidator(this, s_yesOption);

// Customize description based on whether staging channel is enabled
var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration);
var isStagingEnabled = IsStagingChannelAvailable();

_channelOption = new Option<string?>("--channel")
{
Expand Down Expand Up @@ -203,7 +203,7 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul

var allChannels = await InteractionService.ShowStatusAsync(
UpdateCommandStrings.CheckingForUpdates,
async () => await _packagingService.GetChannelsAsync(cancellationToken));
async () => await _packagingService.GetChannelsAsync(cancellationToken, channelName));

if (!string.IsNullOrWhiteSpace(channelName))
{
Expand Down Expand Up @@ -359,6 +359,12 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
return CommandResult.FromExitCode(0);
}

private bool IsStagingChannelAvailable()
{
return KnownFeatures.IsStagingChannelEnabled(_features, _configuration)
|| string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase);
}

private async Task<CommandResult> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken, string? selectedChannel = null)
{
var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
Expand All @@ -369,7 +375,7 @@ private async Task<CommandResult> ExecuteSelfUpdateAsync(ParseResult parseResult
// aspire.config.json, not from any global setting.
if (string.IsNullOrEmpty(channel))
{
var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration);
var isStagingEnabled = IsStagingChannelAvailable();
var channels = isStagingEnabled
? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily }
: new[] { PackageChannelNames.Stable, PackageChannelNames.Daily };
Expand Down
10 changes: 3 additions & 7 deletions src/Aspire.Cli/KnownFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,9 @@ public static IEnumerable<string> GetAllFeatureNames()
/// Note that the channel check reads <c>configuration["channel"]</c> (the layered .NET
/// configuration — environment variables, command-line, global / per-project
/// <c>aspire.config.json#channel</c>), NOT
/// <see cref="CliExecutionContext.IdentityChannel"/>. The staging channel is an
/// opt-in feature: a CLI baked with <c>AspireCliChannel=staging</c> does NOT
/// auto-enable staging in the packaging service unless the user has also set the
/// configuration value (for example via <c>aspire config set channel staging</c> or
/// <c>--channel staging</c>). This is by design — the identity baked into the binary
/// is reserved for selecting the correct hive directory, while feature gating goes
/// through user-visible configuration.
/// <see cref="CliExecutionContext.IdentityChannel"/>. Callers that also need to expose
/// staging for a CLI baked with <c>AspireCliChannel=staging</c> should combine this
/// helper with an identity-channel check.
/// </remarks>
public static bool IsStagingChannelEnabled(IFeatures features, IConfiguration configuration)
{
Expand Down
29 changes: 19 additions & 10 deletions src/Aspire.Cli/Packaging/PackagingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ namespace Aspire.Cli.Packaging;

internal interface IPackagingService
{
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default);
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null);
}

internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration, ILogger<PackagingService> logger) : IPackagingService
{
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default)
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null)
{
var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache, logger);

Expand Down Expand Up @@ -62,10 +62,18 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc

var channels = new List<PackageChannel>([defaultChannel, stableChannel]);

// Add staging channel if feature is enabled (after stable, before daily)
if (KnownFeatures.IsStagingChannelEnabled(features, configuration))
// Add staging channel after stable and before daily. Staging CLI builds should
// dogfood staging packages even before a project-level channel pin exists, and
// callers that already resolved a staging channel from another project directory
// need the channel materialized before they can match it below.
var stagingChannelConfigured = string.Equals(configuration["channel"], PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase);
var stagingChannelRequested = string.Equals(requestedChannelName, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase);
var stagingIdentityChannel = string.Equals(executionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase);
var stagingFeatureEnabled = features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false);
if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel)
{
var stagingChannel = CreateStagingChannel();
var defaultQuality = stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel ? PackageChannelQuality.Both : PackageChannelQuality.Stable;
var stagingChannel = CreateStagingChannel(defaultQuality);
if (stagingChannel is not null)
{
channels.Add(stagingChannel);
Expand All @@ -79,9 +87,9 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
return Task.FromResult<IEnumerable<PackageChannel>>(channels);
}

private PackageChannel? CreateStagingChannel()
private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality)
{
var stagingQuality = GetStagingQuality();
var stagingQuality = GetStagingQuality(defaultQuality);
var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]);

// When quality is Prerelease or Both and no explicit feed override is set,
Expand Down Expand Up @@ -153,7 +161,7 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
return $"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-{truncatedHash}/nuget/v3/index.json";
}

private PackageChannelQuality GetStagingQuality()
private PackageChannelQuality GetStagingQuality(PackageChannelQuality defaultQuality)
{
// Check for configuration override
var overrideQuality = configuration["overrideStagingQuality"];
Expand All @@ -166,8 +174,9 @@ private PackageChannelQuality GetStagingQuality()
}
}

// Default to Stable if not specified or invalid
return PackageChannelQuality.Stable;
// Preserve the historical safe fallback for invalid override values while allowing
// different staging entry points to choose a better default when no override is set.
return string.IsNullOrEmpty(overrideQuality) ? defaultQuality : PackageChannelQuality.Stable;
}

private string? GetStagingPinnedVersion(bool useSharedFeed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,9 @@ private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integratio
File.Copy(userNugetConfig, nugetConfigPath, overwrite: true);
}

var channels = await _packagingService.GetChannelsAsync(cancellationToken);
var configuredChannelName = AspireConfigFile.Load(_appPath)?.Channel
?? AspireJsonConfiguration.Load(_appPath)?.Channel;
var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName);

// Resolve channel sources and add them via RestoreAdditionalProjectSources
// This is additive — it preserves the user's nuget.config and adds channel-specific sources
Expand Down
28 changes: 17 additions & 11 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,10 +1192,19 @@ public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContex
return packageUpdates;
});

var explicitChannelName = context.Channel.Type == Packaging.PackageChannelType.Explicit ? context.Channel.Name : null;
var explicitChannelChanged = explicitChannelName is not null && !string.Equals(config.Channel, explicitChannelName, StringComparisons.CliInputOrOutput);

if (updates.Count == 0 && newSdkVersion is null)
{
if (explicitChannelChanged)
{
config.Channel = explicitChannelName;
SaveConfiguration(config, directory);
}

_interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, UpdateCommandStrings.ProjectUpToDateMessage);
return new UpdatePackagesResult { UpdatesApplied = false };
return new UpdatePackagesResult { UpdatesApplied = explicitChannelChanged };
}

// Display pending updates
Expand All @@ -1221,17 +1230,14 @@ public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContex
{
config.SdkVersion = newSdkVersion;
}
// Persist the channel only when the update resolved an explicit channel (--channel,
// per-project config, or prompt selection). When the resolved channel is Implicit
// — i.e. the user hasn't pinned a channel — leave the project's existing setting
// untouched rather than silently pinning the running CLI's identity, which would
// propagate dev/PR-build identities into the project file. The scaffolding /
// build-time paths intentionally do auto-pin identity (see GuestAppHostProject.cs:354
// and ScaffoldingService.cs:208) — but `aspire update` is a no-pin path: the user
// is updating, not initialising, and we should not change the channel pin state.
if (context.Channel.Type == Packaging.PackageChannelType.Explicit)
// Persist the channel when update resolved an explicit channel. That can come from
// --channel, per-project/global config, prompt selection, or the UpdateCommand
// identity-channel fallback for non-project-reference AppHosts. When the resolved
// channel is Implicit — i.e. no explicit channel source matched — leave the project's
// existing setting untouched rather than pinning the implicit/default channel.
if (explicitChannelName is not null)
{
config.Channel = context.Channel.Name;
config.Channel = explicitChannelName;
}
foreach (var (packageId, _, newVersion) in updates)
{
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ internal static string GenerateIntegrationProjectFile(

try
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken);
var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);

IEnumerable<PackageChannel> explicitChannels;
if (!string.IsNullOrEmpty(requestedChannel))
Expand Down Expand Up @@ -511,7 +511,7 @@ internal static string GenerateIntegrationProjectFile(
return null;
}

var channels = await _packagingService.GetChannelsAsync(cancellationToken);
var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel);
var channel = channels.FirstOrDefault(c =>
c.Type == PackageChannelType.Explicit &&
c.Mappings is { Length: > 0 } &&
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, st
return;
}

var channels = await packagingService.GetChannelsAsync(cancellationToken);
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
var matchingChannel = channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));

Expand Down Expand Up @@ -123,7 +123,7 @@ public async Task<bool> CreateOrUpdateNuGetConfigWithoutPromptAsync(string? chan
return false;
}

var channels = await packagingService.GetChannelsAsync(cancellationToken);
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
var matchingChannel = channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));

Expand Down Expand Up @@ -155,7 +155,7 @@ public async Task<bool> CreateOrUpdateNuGetConfigWithoutPromptAsync(string? chan
/// <exception cref="EmptyChoicesException">Thrown when no template package versions are available across the considered channels.</exception>
public async Task<TemplatePackageSelection> ResolveTemplatePackageAsync(TemplatePackageQuery query, CancellationToken cancellationToken)
{
var allChannels = await packagingService.GetChannelsAsync(cancellationToken);
var allChannels = await packagingService.GetChannelsAsync(cancellationToken, query.RequestedChannel);

// Honor PR hives only when the caller opts in. Init suppresses this so a developer
// with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine.
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Utils/CliDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal class CliDownloader(
public async Task<string> DownloadLatestCliAsync(string channelName, CancellationToken cancellationToken)
{
// Get the channel information from PackagingService
var channels = await packagingService.GetChannelsAsync(cancellationToken);
var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName);
var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));

if (channel is null)
Expand Down
Loading
Loading