Skip to content
Open
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
20 changes: 19 additions & 1 deletion src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
{
// Try to find a channel matching the provided channel/quality
channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase))
?? throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}");
?? throw new ChannelNotFoundException(BuildChannelNotFoundMessage(channelName, allChannels));

if (channelFromConfig)
{
Expand Down Expand Up @@ -304,6 +304,24 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
return 0;
}

private string BuildChannelNotFoundMessage(string channelName, IEnumerable<PackageChannel> allChannels)
{
// For an explicit `--channel staging` request, surface the staging-specific
// unavailability reason (issue #16652) instead of the generic "no channel
// matching" message so users on a daily CLI know why the channel was omitted
// and how to recover (set 'overrideStagingFeed' or use a stable CLI).
if (string.Equals(channelName, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase))
{
var stagingReason = _packagingService.GetStagingChannelUnavailableReason();
if (!string.IsNullOrEmpty(stagingReason))
{
return stagingReason;
}
}

return $"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}";
Comment on lines +307 to +322
}

private async Task<int> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken, string? selectedChannel = null)
{
var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
Expand Down
168 changes: 162 additions & 6 deletions src/Aspire.Cli/Packaging/PackagingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,42 @@

using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
using System.Reflection;
using System.Globalization;

namespace Aspire.Cli.Packaging;

internal interface IPackagingService
{
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default);

/// <summary>
/// When the running CLI cannot deterministically resolve the <c>staging</c> channel,
/// returns a localized, user-facing explanation of why. Returns <see langword="null"/>
/// when the staging channel can be created (or when the staging channel is not enabled
/// at all). Callers that observe a missing <c>staging</c> channel should consult this
/// to produce a clearer error message.
/// </summary>
public string? GetStagingChannelUnavailableReason();
}

/// <summary>
/// Internal configuration keys consumed by <see cref="PackagingService"/>.
/// </summary>
internal static class PackagingConfigurationKeys
{
/// <summary>
/// Test-only override of the CLI assembly informational version used when deciding
/// whether the running CLI is a daily/CI build. Production callers should never set
/// this value; it exists so unit tests can deterministically simulate stable, blessed
/// preview/RC, and daily CLI builds without depending on the actual assembly version
/// of <c>Aspire.Cli.dll</c> at test time.
/// </summary>
public const string CliVersionForTesting = "internal:packaging:cliVersionForTesting";
}

internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration, ILogger<PackagingService> logger) : IPackagingService
Expand Down Expand Up @@ -79,8 +104,55 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
return Task.FromResult<IEnumerable<PackageChannel>>(channels);
}

public string? GetStagingChannelUnavailableReason()
{
// The 'staging' channel is only meaningful when the feature/channel-config
// explicitly enabled it. If it isn't enabled, there's no "unavailability"
// to report — the channel simply isn't created.
if (!KnownFeatures.IsStagingChannelEnabled(features, configuration))
{
return null;
}

// If the user has supplied an explicit staging feed override they are taking
// ownership of where staging packages come from, so any CLI build is allowed.
var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]);
if (hasExplicitFeedOverride)
{
return null;
}
Comment on lines +117 to +123

// When the running CLI is itself a daily/CI build, there is no deterministic
// way to derive a real staging feed: a SHA-specific darc-pub-* feed is not
// created for daily commits, and falling back to the shared dotnet9 feed would
// resolve to daily packages — which is the bug tracked by #16652. Refuse to
// synthesize a staging channel in that case so the caller fails fast with an
// actionable error message instead of silently downgrading to daily packages.
var cliVersion = GetCliInformationalVersionForStagingDecision();
if (IsCliPrereleaseDailyBuild(cliVersion))
{
return string.Format(
CultureInfo.CurrentCulture,
PackagingStrings.StagingChannelUnavailableForDailyCliFormat,
cliVersion ?? "unknown");
}

return null;
}

private PackageChannel? CreateStagingChannel()
{
var unavailableReason = GetStagingChannelUnavailableReason();
if (unavailableReason is not null)
{
// Logged once per channel enumeration so users can see why staging was
// omitted without inspecting source. UpdateCommand surfaces the same
// message via ChannelNotFoundException when the user explicitly passes
// --channel staging.
logger.LogWarning("{UnavailableReason}", unavailableReason);
return null;
}

var stagingQuality = GetStagingQuality();
var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]);

Expand All @@ -105,9 +177,95 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
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, logger: logger);

// Surface the resolved feed so users can verify channel resolution from the
// CLI logs without disclosing any embedded credentials or signed parameters.
logger.LogInformation(
"Resolved 'staging' channel: feed='{StagingFeedUrl}', quality='{Quality}', pinnedVersion='{PinnedVersion}'.",
GetRedactedFeedUrlForLogging(stagingFeedUrl),
stagingQuality,
pinnedVersion ?? "(none)");

return stagingChannel;
}

private static string GetRedactedFeedUrlForLogging(string feedUrl)
{
if (!Uri.TryCreate(feedUrl, UriKind.Absolute, out var uri))
{
return "(invalid or non-standard feed URL)";
}

var builder = new UriBuilder(uri)
{
UserName = string.Empty,
Password = string.Empty,
Query = string.Empty,
Fragment = string.Empty
};

return builder.Uri.AbsoluteUri;
}

/// <summary>
/// Returns the CLI informational version string used by staging-channel decisions.
/// Honors the test-only configuration override so tests can deterministically
/// simulate stable / blessed-prerelease / daily CLI builds.
/// </summary>
private string? GetCliInformationalVersionForStagingDecision()
{
var testOverride = configuration[PackagingConfigurationKeys.CliVersionForTesting];
if (!string.IsNullOrWhiteSpace(testOverride))
{
return testOverride;
}

Comment on lines +211 to +221
try
{
return VersionHelper.GetDefaultTemplateVersion();
}
catch (InvalidOperationException)
{
// Cannot determine assembly version; treat as unknown so callers can decide.
return null;
}
}

/// <summary>
/// Determines whether the supplied informational version represents a daily/CI
/// build of the Aspire CLI as opposed to either a stable release or a blessed
/// preview/RC build that has its own staging feed.
/// Heuristic: stable releases have no prerelease label; blessed preview/RC builds
/// use simple labels such as <c>preview.1</c> or <c>rc.1</c> (≤ 2 prerelease
/// identifiers). Daily/CI builds add date+revision suffixes from Arcade
/// (e.g. <c>preview.1.26210.1</c>, ≥ 3 identifiers), making them easy to distinguish.
/// </summary>
internal static bool IsCliPrereleaseDailyBuild(string? informationalVersion)
{
if (string.IsNullOrWhiteSpace(informationalVersion))
{
// If we cannot determine the version, err on the side of safety and treat
// it as a daily build so we don't silently resolve to daily packages.
return true;
}

var withoutBuildMetadata = informationalVersion.Split('+')[0];
if (!SemVersion.TryParse(withoutBuildMetadata, SemVersionStyles.Strict, out var sv))
{
// Unparseable version — also err on the side of safety.
return true;
}

if (!sv.IsPrerelease)
{
// Stable release.
return false;
}

// Blessed prereleases (preview.1, rc.1, etc.) have at most 2 dot-separated
// identifiers. Daily builds add a date + revision suffix giving 3+ identifiers.
return sv.PrereleaseIdentifiers.Count > 2;
}

private string? GetStagingFeedUrl(bool useSharedFeed)
{
// Check for configuration override first
Expand All @@ -130,11 +288,9 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc

// Extract commit hash from assembly version to build staging feed URL
// Staging feed URL template: https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-{commitHash}/nuget/v3/index.json
var assembly = Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false)
.OfType<AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion;
// Honors the test seam so unit tests can deterministically simulate stable
// CLI builds with a known commit hash.
var informationalVersion = GetCliInformationalVersionForStagingDecision();

if (informationalVersion is null)
{
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Cli/Resources/PackagingStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/Aspire.Cli/Resources/PackagingStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,8 @@
<value>based on NuGet.config</value>
<comment>Source details text shown for packages from implicit channel or channels without Aspire* package source mappings</comment>
</data>
<data name="StagingChannelUnavailableForDailyCliFormat" xml:space="preserve">
<value>The 'staging' channel cannot be resolved deterministically when running a daily/CI Aspire CLI build (CLI version: '{0}'). To update against staging, either run a stable Aspire CLI build, or set the 'overrideStagingFeed' configuration value to an explicit staging feed URL (for example: 'aspire config set overrideStagingFeed &lt;url&gt;').</value>
<comment>Error/warning shown when the 'staging' package channel is requested but the running CLI is itself a daily/CI build with no explicit staging feed override. {0} is the CLI's informational version.</comment>
</data>
</root>
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/PackagingStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/PackagingStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/PackagingStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/PackagingStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/PackagingStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading