// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.Security;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
namespace Aspire.Cli.Packaging;
internal interface IPackagingService
{
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null);
/// <summary>
/// Returns a user-facing reason explaining why the <c>staging</c> package channel cannot be
/// synthesized for the running CLI, or <see langword="null"/> when staging IS available.
/// </summary>
/// <remarks>
/// On a CLI whose baked <c>AspireCliChannel</c> identity is <c>daily</c>, <c>local</c>, or
/// <c>pr-<N></c>, there is no deterministic way to produce a real staging feed:
/// those identities are not officially published release-branch builds, so no SHA-specific
/// darc feed (<c>darc-pub-microsoft-aspire-<hash></c>) carries their packages, and
/// falling back to the shared daily feed silently resolves daily packages instead of staging
/// ones. To avoid that downgrade
/// (see <see href="https://github.com/microsoft/aspire/issues/16652"/>), the service refuses
/// to fabricate a staging channel from those identities unless the caller has set
/// <c>overrideStagingFeed</c> or enabled the staging feature flag.
/// </remarks>
string? GetStagingChannelUnavailableReason();
}
internal class PackagingService : IPackagingService
{
// Configuration key used to override the staging feed URL. When non-empty,
// PackagingService treats staging as available regardless of the CLI's
// identity channel (see IsStagingChannelSynthesisAllowed). Surfaced from
// tests via InternalsVisibleTo so a single literal change can't drift.
internal const string OverrideStagingFeedConfigKey = "overrideStagingFeed";
// Diagnostic overrides for validating staging FEED ROUTING from a locally built CLI without
// having to produce a real official staging build. They are intentionally scoped to the
// staging-feed decisions in this service (they do NOT change the global
// CliExecutionContext.IdentityChannel used for hive/packages directory lookups), so a plain
// local dev build can be made to derive and resolve from a real darc-pub-microsoft-aspire-<sha>
// feed exactly the way an official staging build would. See docs/cli-staging-validation.md.
//
// overrideCliIdentityChannel - forces the identity used for staging-feed decisions
// (validated against the known channel set). Set to
// `staging` to exercise the staging-identity darc path.
// overrideCliInformationalVersion - forces the AssemblyInformationalVersion used to derive
// the SHA-specific feed URL, e.g.
// `13.4.0-preview.1.26280.6+<full-commit-hash>`.
//
// NOTE: These only route to a feed; they do not create one. They are typically useful only
// once the darc-pub-microsoft-aspire-<sha> feed actually exists for the specific commit/version
// you are emulating (i.e. an official build for that SHA has been published). Until then the
// derived feed URL resolves to nothing and restore will fail to find packages.
internal const string OverrideCliIdentityChannelConfigKey = "overrideCliIdentityChannel";
internal const string OverrideCliInformationalVersionConfigKey = "overrideCliInformationalVersion";
private readonly CliExecutionContext _executionContext;
/// <summary>
/// Resolves the nuget.org service-index URL the CLI should write into the
/// <c>PackageMapping</c>s it generates. Returns the override supplied by
/// <c>ASPIRE_CLI_NUGET_SERVICE_INDEX</c> or the <c>nugetServiceIndexOverride</c>
/// field of <c>.aspire-install.json</c> when set, otherwise the canonical
/// <see cref="PackageSources.NuGetOrg"/>. This is a write-side rewrite
/// only; URLs read out of existing user <c>NuGet.config</c> files are not
/// touched. See <c>docs/specs/cli-identity-sidecar.md</c>.
/// </summary>
private string NuGetOrgUrl => _executionContext.NuGetServiceIndexOverride ?? PackageSources.NuGetOrg;
private readonly INuGetPackageCache _nuGetPackageCache;
private readonly IFeatures _features;
private readonly IConfiguration _configuration;
private readonly ILogger<PackagingService> _logger;
private readonly Func<string?> _processPathProvider;
// Provides the running CLI's AssemblyInformationalVersion (which carries the +<commitHash>
// build metadata used to derive the SHA-specific darc-pub-microsoft-aspire-<hash> staging
// feed). Defaults to reading the Aspire.Cli assembly; tests inject a deterministic value
// because the version baked into the test-host assembly varies by build configuration, which
// otherwise makes the derived darc feed URL non-deterministic (and therefore un-assertable).
private readonly Func<string?> _cliInformationalVersionProvider;
// Cached result of the staging-channel availability check. The inputs (CLI identity,
// overrideStagingFeed, StagingChannelEnabled feature) are effectively static for the
// process lifetime, so computing this once avoids re-formatting the localized reason
// string on every GetChannelsAsync call (callers fan out across NewCommand,
// UpdateCommand, IntegrationPackageSearchService, NuGetPackagePrefetcher, etc.).
private readonly Lazy<string?> _stagingUnavailableReasonCache;
public PackagingService(
CliExecutionContext executionContext,
INuGetPackageCache nuGetPackageCache,
IFeatures features,
IConfiguration configuration,
ILogger<PackagingService> logger,
Func<string?>? processPathProvider = null,
Func<string?>? cliInformationalVersionProvider = null)
{
_executionContext = executionContext;
_nuGetPackageCache = nuGetPackageCache;
_features = features;
_configuration = configuration;
_logger = logger;
_processPathProvider = processPathProvider ?? (() => Environment.ProcessPath);
_cliInformationalVersionProvider = cliInformationalVersionProvider ?? GetCliInformationalVersionDefault;
_stagingUnavailableReasonCache = new Lazy<string?>(ComputeStagingChannelUnavailableReason);
}
// One-shot guards so the refusal warning / successful-resolution info line are emitted
// at most once per CLI process instead of on every GetChannelsAsync invocation. Many
// commands (and the background NuGetPackagePrefetcher) call GetChannelsAsync repeatedly;
// logging on each call produced excessive noise — particularly the refusal warning when
// a project's aspire.config.json pins `channel: staging` on a daily/local CLI.
private int _stagingRefusalLogged;
private int _stagingResolutionLogged;
private int _stagingFeedDerivationFailedLogged;
private int _stagingDiagnosticOverrideLogged;
public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null)
{
// Emit the diagnostic-override warning up front so any invocation that has the overrides set
// leaves a trace, regardless of whether a staging channel ends up being synthesized below
// (e.g. an override that ultimately resolves to a non-staging identity still warns).
WarnIfStagingDiagnosticOverridesActive();
var nugetOrg = NuGetOrgUrl;
var defaultChannel = PackageChannel.CreateImplicitChannel(_nuGetPackageCache, _features, _logger, currentCliVersion: _executionContext.IdentitySdkVersion, validateTemplatePackageMetadataPrefetching: TemplatePackageMetadataPrefetchingValidation);
var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[]
{
new PackageMapping(PackageMapping.AllPackages, nugetOrg)
}, _nuGetPackageCache, _features, _logger, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily", currentCliVersion: _executionContext.IdentitySdkVersion, validateTemplatePackageMetadataPrefetching: TemplatePackageMetadataPrefetchingValidation);
var dailyChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, new[]
{
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"),
new PackageMapping(PackageMapping.AllPackages, nugetOrg)
}, _nuGetPackageCache, _features, _logger, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily", currentCliVersion: _executionContext.IdentitySdkVersion, validateTemplatePackageMetadataPrefetching: TemplatePackageMetadataPrefetchingValidation);
var prPackageChannels = new List<PackageChannel>();
// Cannot use HiveDirectory.Exists here because it blows up on the
// intermediate directory structure which may not exist in some
// contexts (e.g. in our Codespace where we have the CLI on the
// path but not in the $HOME/.aspire/bin folder).
if (_executionContext.HivesDirectory.Exists)
{
var prHives = _executionContext.HivesDirectory.GetDirectories();
foreach (var prHive in prHives)
{
prPackageChannels.Add(CreateLocalHiveChannel(prHive.Name, new DirectoryInfo(Path.Combine(prHive.FullName, "packages"))));
}
}
if (TryResolvePrInstallPackagesDirectory(TryGetProcessPathForPrInstallDiscovery(), _executionContext.IdentityChannel) is { } prInstallPackagesDirectory)
{
// The install-prefix hive belongs to the running PR CLI. Prefer it over a same-named
// default hive so a stale ~/.aspire/hives/pr-<N> cannot mask the co-installed packages.
prPackageChannels.RemoveAll(c => string.Equals(c.Name, _executionContext.IdentityChannel, StringComparisons.ChannelName));
prPackageChannels.Add(CreateLocalHiveChannel(_executionContext.IdentityChannel, prInstallPackagesDirectory));
}
var channels = new List<PackageChannel>([defaultChannel, stableChannel]);
// 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, StringComparisons.ChannelName);
var stagingChannelRequested = string.Equals(requestedChannelName, PackageChannelNames.Staging, StringComparisons.ChannelName);
var stagingIdentityChannel = string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Staging, StringComparisons.ChannelName);
var stagingFeatureEnabled = _features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false);
if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel)
{
// Default quality selection rules (per staging entry point). NOTE: quality controls
// version FILTERING only (which versions in the feed are eligible); it no longer
// selects the feed itself. Feed PROVENANCE is identity-driven inside
// ShouldUseSharedStagingFeed — a staging-identity CLI always resolves Aspire.* from its
// own SHA-specific darc-pub-microsoft-aspire-<commit> feed.
// - Explicit user opt-in (`stagingChannelConfigured`, `stagingChannelRequested`): Both.
// The user picked staging deliberately; they get the broadest matching window.
// - `stagingFeatureEnabled` only (no other staging signal): Stable. Preserves the
// pre-existing behavior of the staging feature flag.
// - `stagingIdentityChannel` (the running CLI itself self-identifies as staging): Both.
// A single official staging build can publish stable-shaped packages and deliberately
// prerelease-only integrations from the same SHA-specific feed. Restricting a
// stable-shaped staging CLI to Stable hides those integrations from broad discovery.
// See https://github.com/microsoft/aspire/issues/19423.
PackageChannelQuality defaultQuality;
if (stagingIdentityChannel)
{
defaultQuality = PackageChannelQuality.Both;
}
else if (stagingChannelConfigured || stagingChannelRequested)
{
defaultQuality = PackageChannelQuality.Both;
}
else
{
defaultQuality = PackageChannelQuality.Stable;
}
var stagingChannel = CreateStagingChannel(defaultQuality);
if (stagingChannel is not null)
{
channels.Add(stagingChannel);
}
}
// Add daily and PR channels after staging
channels.Add(dailyChannel);
channels.AddRange(prPackageChannels);
if (_executionContext.IdentityPackagesDirectory is { } identityPackagesDirectory)
{
// ASPIRE_CLI_PACKAGES / the sidecar `packages` field points the CLI's Aspire* feed directly
// at a flat directory of freshly built .nupkg files (e.g. artifacts/packages/<Config>/Shipping)
// instead of a hive staged under ~/.aspire/hives, so a locally built CLI can resolve locally
// built packages without copying them. The synthesized channel takes the running CLI's
// identity-channel name and wins over ANY same-named channel synthesized above — whether a
// built-in (e.g. emulating `daily`/`stable` while supplying local packages), a discovered
// ~/.aspire/hives/<channel> hive, or a PR-install hive — because the explicit override is the
// most specific signal of intent. We replace it here (after the full list is built) rather
// than earlier so built-in channels are covered too. See docs/specs/cli-identity-sidecar.md.
EnsurePackagesOverrideDirectoryIsUnambiguous(identityPackagesDirectory);
var replacedChannelCount = channels.RemoveAll(c => string.Equals(c.Name, _executionContext.IdentityChannel, StringComparisons.ChannelName));
_logger.LogDebug(
"Synthesized package channel '{Channel}' from the packages override directory '{PackagesDirectory}', replacing {ReplacedChannelCount} same-named channel(s).",
_executionContext.IdentityChannel,
identityPackagesDirectory.FullName,
replacedChannelCount);
channels.Add(CreateLocalHiveChannel(_executionContext.IdentityChannel, identityPackagesDirectory));
}
return Task.FromResult<IEnumerable<PackageChannel>>(channels);
}
private Action TemplatePackageMetadataPrefetchingValidation => () => ValidateTemplatePackageMetadataPrefetching();
[Conditional("DEBUG")]
private void ValidateTemplatePackageMetadataPrefetching()
{
if (_executionContext.Command is BaseCommand { PrefetchesTemplatePackageMetadata: false } command)
{
throw new PackageMetadataPrefetchingValidationException($"Command '{command.Name}' consumes template package metadata but does not enable {nameof(BaseCommand.PrefetchesTemplatePackageMetadata)}.");
}
}
private string? TryGetProcessPathForPrInstallDiscovery()
{
try
{
return _processPathProvider();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get process path while discovering PR package hive.");
return null;
}
}
private PackageChannel CreateLocalHiveChannel(string name, DirectoryInfo packagesDirectory)
{
var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory);
// Use forward slashes for cross-platform NuGet config compatibility
var packagesPath = PathNormalizer.NormalizePathForStorage(packagesDirectory.FullName);
return PackageChannel.CreateExplicitChannel(name, PackageChannelQuality.Both, new[]
{
new PackageMapping("Aspire*", packagesPath),
new PackageMapping(PackageMapping.AllPackages, NuGetOrgUrl)
}, _nuGetPackageCache, _features, _logger, pinnedVersion: pinnedVersion, currentCliVersion: _executionContext.IdentitySdkVersion, validateTemplatePackageMetadataPrefetching: TemplatePackageMetadataPrefetchingValidation);
}
internal static DirectoryInfo? TryResolvePrInstallPackagesDirectory(string? processPath, string identityChannel)
{
if (!identityChannel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrEmpty(processPath))
{
return null;
}
DirectoryInfo binaryDirectory;
try
{
var binaryDirectoryPath = Path.GetDirectoryName(Path.GetFullPath(processPath));
if (string.IsNullOrEmpty(binaryDirectoryPath))
{
return null;
}
binaryDirectory = new DirectoryInfo(binaryDirectoryPath);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException or SecurityException)
{
return null;
}
// Archive installs from get-aspire-cli-pr place the binary at:
// <prefix>/dogfood/pr-<N>/bin/aspire
// and the matching packages at:
// <prefix>/hives/pr-<N>/packages
if (!string.Equals(binaryDirectory.Name, "bin", StringComparison.OrdinalIgnoreCase) ||
binaryDirectory.Parent is not { } prDirectory ||
!string.Equals(prDirectory.Name, identityChannel, StringComparisons.ChannelName) ||
prDirectory.Parent is not { } dogfoodDirectory ||
!string.Equals(dogfoodDirectory.Name, "dogfood", StringComparison.OrdinalIgnoreCase) ||
dogfoodDirectory.Parent is not { } installPrefix)
{
return null;
}
var packagesDirectory = new DirectoryInfo(Path.Combine(installPrefix.FullName, "hives", identityChannel, "packages"));
return packagesDirectory.Exists ? packagesDirectory : null;
}
// Default informational-version provider. Honors the overrideCliInformationalVersion diagnostic
// override (so the SHA-specific darc feed can be derived deterministically from a locally built
// CLI) before falling back to the resolved identity. IdentityVersion carries the version without
// build metadata and IdentityCommit carries the +<sha>; they are recombined here so the
// staging-feed SHA derivation honors ASPIRE_CLI_VERSION / ASPIRE_CLI_COMMIT / the install sidecar
// rather than reading the physical assembly informational version.
private string? GetCliInformationalVersionDefault()
{
var overrideVersion = _configuration[OverrideCliInformationalVersionConfigKey];
if (!string.IsNullOrEmpty(overrideVersion))
{
return overrideVersion;
}
var version = _executionContext.IdentityVersion;
var commit = _executionContext.IdentityCommit;
return string.IsNullOrEmpty(commit) ? version : $"{version}+{commit}";
}
private static string StripBuildMetadata(string version)
{
var plusIndex = version.IndexOf('+');
return plusIndex >= 0 ? version[..plusIndex] : version;
}
// Returns the identity channel used for staging-feed routing decisions. Normally this is the
// CLI build's baked identity (CliExecutionContext.IdentityChannel). For local validation of
// staging feed routing, overrideCliIdentityChannel can force a different identity (validated
// against the known channel set via IdentityChannelReader.IsValidChannel) WITHOUT changing the
// global identity used elsewhere (hive/packages directory lookups), keeping the blast radius
// limited to feed provenance. Invalid override values are ignored — we fall back to the real
// identity, mirroring how overrideStagingFeed ignores malformed URLs.
private string GetEffectiveIdentityChannel()
{
var overrideChannel = _configuration[OverrideCliIdentityChannelConfigKey];
if (!string.IsNullOrEmpty(overrideChannel) && IdentityChannelReader.IsValidChannel(overrideChannel))
{
return overrideChannel;
}
return _executionContext.IdentityChannel;
}
// Emits a single warning when either staging diagnostic override is active, so a normal CLI
// invocation can't silently resolve Aspire.* from an overridden identity/feed without a trace
// in the logs. Emitted at most once per process to avoid noise across repeated GetChannelsAsync
// calls.
private void WarnIfStagingDiagnosticOverridesActive()
{
var identityOverride = _configuration[OverrideCliIdentityChannelConfigKey];
var versionOverride = _configuration[OverrideCliInformationalVersionConfigKey];
if (string.IsNullOrEmpty(identityOverride) && string.IsNullOrEmpty(versionOverride))
{
return;
}
if (Interlocked.Exchange(ref _stagingDiagnosticOverrideLogged, 1) == 0)
{
_logger.LogWarning(
"Staging feed-routing diagnostic overrides are active: {IdentityKey}={IdentityValue}, {VersionKey}={VersionValue}. " +
"These are intended only for local validation of staging feed routing and must not be set on a normal CLI.",
OverrideCliIdentityChannelConfigKey,
string.IsNullOrEmpty(identityOverride) ? "(unset)" : identityOverride,
OverrideCliInformationalVersionConfigKey,
string.IsNullOrEmpty(versionOverride) ? "(unset)" : versionOverride);
}
}
private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality)
{
// Refuse to synthesize a staging channel on CLI identities that cannot produce a real
// staging feed (daily, local, pr-<N>). Silently falling back to the shared daily feed or
// a non-existent SHA-specific darc feed is the bug tracked by
// https://github.com/microsoft/aspire/issues/16652. The escape hatches (explicit
// overrideStagingFeed, or the StagingChannelEnabled feature flag) are honored inside
// IsStagingChannelSynthesisAllowed below.
var unavailableReason = GetStagingChannelUnavailableReason();
if (unavailableReason is not null)
{
if (Interlocked.Exchange(ref _stagingRefusalLogged, 1) == 0)
{
_logger.LogWarning("Refusing to synthesize 'staging' package channel: {Reason}", unavailableReason);
}
return null;
}
var stagingQuality = GetStagingQuality(defaultQuality);
var hasExplicitFeedOverride = !string.IsNullOrEmpty(_configuration[OverrideStagingFeedConfigKey]);
// Feed PROVENANCE is decided by the CLI build identity; version FILTERING is decided by
// quality. These are independent concerns and must not be conflated (see
// https://github.com/microsoft/aspire/issues/16652 for the original misroute, and the
// staging-identity prerelease regression that motivated separating them).
var effectiveIdentityChannel = GetEffectiveIdentityChannel();
var useSharedFeed = ShouldUseSharedStagingFeed(hasExplicitFeedOverride, stagingQuality, effectiveIdentityChannel);
var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed);
if (stagingFeedUrl is null)
{
// Reaching here means synthesis was allowed (IsStagingChannelSynthesisAllowed passed) but the
// feed URL could not be produced. The only way that happens without an explicit override is the
// darc path failing to derive a commit hash from the CLI's AssemblyInformationalVersion (null,
// or no '+<hash>' build metadata). For a staging-identity CLI this should not occur on an
// officially published build, so surface it as a warning rather than silently dropping the
// channel — otherwise the caller just sees a missing 'staging' channel with no diagnostic
// (GetStagingChannelUnavailableReason() returns null because synthesis was permitted).
if (Interlocked.Exchange(ref _stagingFeedDerivationFailedLogged, 1) == 0)
{
_logger.LogWarning(
"Could not synthesize 'staging' package channel: failed to derive a staging feed URL for CLI identity '{Identity}' (no commit hash in the CLI version and no overrideStagingFeed set).",
effectiveIdentityChannel);
}
return null;
}
var pinnedVersion = GetStagingPinnedVersion(useSharedFeed);
var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[]
{
new PackageMapping("Aspire*", stagingFeedUrl),
new PackageMapping(PackageMapping.AllPackages, NuGetOrgUrl)
}, _nuGetPackageCache, _features, _logger, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion, currentCliVersion: _executionContext.IdentitySdkVersion, validateTemplatePackageMetadataPrefetching: TemplatePackageMetadataPrefetchingValidation);
// Surface the resolved staging routing so users can see what `--channel staging` actually
// picked (the "show what was resolved" suggestion from the issue RCA). Pinned version is
// optional and only set when configured via stagingPinToCliVersion. Emitted once per
// process to avoid repeating on every GetChannelsAsync call.
if (Interlocked.Exchange(ref _stagingResolutionLogged, 1) == 0)
{
_logger.LogInformation(
"Resolved 'staging' channel: feed={FeedUrl}, quality={Quality}, pinnedVersion={PinnedVersion}",
stagingFeedUrl,
stagingQuality,
pinnedVersion ?? "(none)");
}
return stagingChannel;
}
/// <inheritdoc />
public string? GetStagingChannelUnavailableReason() => _stagingUnavailableReasonCache.Value;
// Decides whether the synthesized staging channel routes Aspire.* at the SHARED dnceng/dotnet9
// daily feed (true) or at the SHA-specific darc-pub-microsoft-aspire-<commit> feed (false).
//
// The rule is identity-driven, NOT version-shape-driven:
// * Explicit overrideStagingFeed -> false. The caller named an exact feed; GetStagingFeedUrl
// returns it verbatim, so the shared-vs-darc distinction is moot.
// * staging IDENTITY -> false (always its own darc feed, any version shape). A CLI
// whose baked AspireCliChannel is `staging` is an officially published release-branch build,
// and darc publishes a per-commit darc-pub-microsoft-aspire-<commit> feed for EVERY such
// build — prerelease-shaped 13.4.0-preview.* and stable-shaped 13.4.0 alike. That feed is
// derived from the CLI's own commit, so it always carries the CLI's matching packages.
// Falling back to the shared dotnet9 daily feed (which only carries main-branch daily
// packages) silently resolves the wrong packages for polyglot apphosts while C# apphosts —
// whose nuget.config has the darc feed baked in — resolve correctly. That asymmetry is the
// bug this method fixes. (A missing darc feed for an officially published staging build is a
// publish/infra failure that should surface as an unresolved package, not be masked by a
// silent downgrade to daily packages.)
// * any other identity opting into staging (stable identity via config pin / StagingChannelEnabled
// feature) -> keep the historical quality-based routing: non-Stable quality uses the shared
// feed, Stable quality uses the SHA feed. Those identities do not own a release-branch darc
// feed of their own, so this preserves prior behavior unchanged.
private static bool ShouldUseSharedStagingFeed(bool hasExplicitFeedOverride, PackageChannelQuality stagingQuality, string identityChannel)
{
if (hasExplicitFeedOverride)
{
return false;
}
if (string.Equals(identityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName))
{
return false;
}
return stagingQuality is not PackageChannelQuality.Stable;
}
private string? ComputeStagingChannelUnavailableReason()
{
if (IsStagingChannelSynthesisAllowed())
{
return null;
}
return string.Format(
CultureInfo.CurrentCulture,
PackagingStrings.StagingChannelUnavailableOnDailyCli,
GetEffectiveIdentityChannel());
}
private bool IsStagingChannelSynthesisAllowed()
{
// Explicit feed override always wins: the caller has told us exactly which feed to use,
// so we don't need to infer one from the CLI identity.
if (!string.IsNullOrEmpty(_configuration[OverrideStagingFeedConfigKey]))
{
return true;
}
// The staging feature flag is an explicit developer/test opt-in that predates this
// gating; preserve it for back-compat with existing developer workflows.
if (_features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false))
{
return true;
}
// Only stable and staging CLI builds can deterministically resolve a staging feed:
// - stable: the SHA-specific darc-pub-microsoft-aspire-<hash> feed exists for the
// stable release branch commit baked into the CLI.
// - staging: dogfoods staging packages (see #17155 which auto-registers the staging
// channel for the staging CLI identity).
// For daily, local, and pr-<N> identities, falling back to either the SHA feed (no real
// darc feed exists) or the shared daily feed silently resolves daily packages — the
// exact bug tracked by https://github.com/microsoft/aspire/issues/16652.
return string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Stable, StringComparisons.ChannelName)
|| string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Staging, StringComparisons.ChannelName);
}
private string? GetStagingFeedUrl(bool useSharedFeed)
{
// Check for _configuration override first
var overrideFeed = _configuration[OverrideStagingFeedConfigKey];
if (!string.IsNullOrEmpty(overrideFeed))
{
// Validate that the override URL is well-formed
if (UrlHelper.IsHttpUrl(overrideFeed))
{
return overrideFeed;
}
// Invalid URL, fall through to default behavior
}
// Use the shared daily feed when the routing policy selected it (see ShouldUseSharedStagingFeed).
if (useSharedFeed)
{
return "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json";
}
// Derive the SHA-specific staging feed from the CLI's own commit. The commit comes from
// CliExecutionContext.IdentityCommit (ASPIRE_CLI_COMMIT → install sidecar → assembly build
// metadata), so a locally built CLI emulating an official build derives the same feed. The
// injectable provider is still consulted first so tests can pin a deterministic
// informational version (the test-host assembly version is non-deterministic). Example:
// informational "13.4.0-preview.1.26280.6+abcdef12...." or IdentityCommit "abcdef12...."
// yields the feed:
// https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json
var commitHash = ExtractCommitFromInformationalVersion(_cliInformationalVersionProvider())
?? (string.IsNullOrEmpty(_executionContext.IdentityCommit) ? null : _executionContext.IdentityCommit);
if (string.IsNullOrEmpty(commitHash))
{
return null;
}
// Env/sidecar commits are guaranteed >= 8 chars by IdentityResolver.ValidateCommit; only the
// never-validated assembly informational version could be shorter (e.g. a test or malformed
// build), so fall back to the raw value there rather than throwing on this resolve path.
var truncatedHash = commitHash.Length >= 8 ? commitHash[..8] : commitHash;
return $"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-{truncatedHash}/nuget/v3/index.json";
}
// Extracts the commit hash from the build-metadata suffix of an informational version, e.g.
// "13.4.0-preview.1.26280.6+abcdef1234..." -> "abcdef1234..."
// Returns null when there is no usable "+<sha>" suffix.
private static string? ExtractCommitFromInformationalVersion(string? informationalVersion)
{
if (informationalVersion is null)
{
return null;
}
var plusIndex = informationalVersion.IndexOf('+');
if (plusIndex < 0 || plusIndex + 1 >= informationalVersion.Length)
{
return null;
}
return informationalVersion[(plusIndex + 1)..];
}
private PackageChannelQuality GetStagingQuality(PackageChannelQuality defaultQuality)
{
// Check for _configuration override
var overrideQuality = _configuration["overrideStagingQuality"];
if (!string.IsNullOrEmpty(overrideQuality))
{
// Try to parse the quality value (case-insensitive)
if (Enum.TryParse<PackageChannelQuality>(overrideQuality, ignoreCase: true, out var quality))
{
return quality;
}
}
// 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)
{
// Only pin versions when using the shared feed and the config flag is set
var pinToCliVersion = _configuration["stagingPinToCliVersion"];
if (!useSharedFeed || !string.Equals(pinToCliVersion, "true", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Pin to the running CLI's identity version (honors ASPIRE_CLI_VERSION / the install
// sidecar / the overrideCliInformationalVersion diagnostic), stripped of build metadata.
var informationalVersion = _cliInformationalVersionProvider();
return string.IsNullOrEmpty(informationalVersion) ? null : StripBuildMetadata(informationalVersion);
}
// Local hive channels point at a flat directory of .nupkg files instead of a searchable feed.
// Derive a concrete Aspire version from the hive contents and pin the channel to it so template
// and package resolution stays on the same locally built version instead of asking NuGet for "latest".
// Prefer Aspire.ProjectTemplates because it drives `aspire new`, then fall back to common packages
// that are still present when the templates package is absent.
private static string? GetLocalHivePinnedVersion(DirectoryInfo packagesDirectory)
{
if (!packagesDirectory.Exists)
{
return null;
}
return FindHighestVersion("Aspire.ProjectTemplates")
?? FindHighestVersion("Aspire.Hosting")
?? FindHighestVersion("Aspire.AppHost.Sdk");
string? FindHighestVersion(string packageId)
{
return packagesDirectory
.EnumerateFiles($"{packageId}.*.nupkg")
.Select(static file => file.Name)
.Select(fileName => fileName[(packageId.Length + 1)..^".nupkg".Length])
.Where(version => SemVersion.TryParse(version, SemVersionStyles.Strict, out _))
.OrderByDescending(version => SemVersion.Parse(version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
.FirstOrDefault();
}
}
// The ASPIRE_CLI_PACKAGES override maps Aspire* -> a flat directory feed. A flat directory has no
// "latest stable vs latest prerelease" semantics: when an id appears with more than one version,
// NuGet resolves the highest, silently masking the version the developer meant to test. Fail fast
// with an actionable message instead of resolving the wrong package. Only Aspire* ids are checked
// because the synthesized channel only routes Aspire* to this directory (everything else goes to
// nuget.org), so unrelated multi-versioned packages in the same folder are harmless.
private static void EnsurePackagesOverrideDirectoryIsUnambiguous(DirectoryInfo packagesDirectory)
{
if (!packagesDirectory.Exists)
{
throw new InvalidOperationException(string.Format(
CultureInfo.CurrentCulture,
PackagingStrings.PackagesOverrideDirectoryNotFound,
packagesDirectory.FullName));
}
var versionsById = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
// Enumerate recursively to match how the synthesized channel discovers local packages
// (PackageChannel.EnumerateLocalPackageFiles uses SearchOption.AllDirectories), so duplicates
// sitting in nested folders cannot slip past this guardrail.
foreach (var file in packagesDirectory.EnumerateFiles("Aspire*.nupkg", SearchOption.AllDirectories))
{
if (!TryParseNupkgFileName(file.Name, out var id, out var version))
{
continue;
}
if (!versionsById.TryGetValue(id, out var versions))
{
versions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
versionsById[id] = versions;
}
versions.Add(version);
}
var duplicates = versionsById
.Where(static kvp => kvp.Value.Count > 1)
.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
if (duplicates.Count > 0)
{
// e.g. "Aspire.Hosting (13.4.1, 13.4.2); Aspire.Hosting.Azure (13.4.1, 13.4.2)"
var details = string.Join("; ", duplicates.Select(static kvp =>
$"{kvp.Key} ({string.Join(", ", kvp.Value)})"));
throw new InvalidOperationException(string.Format(
CultureInfo.CurrentCulture,
PackagingStrings.PackagesOverrideDirectoryHasDuplicateVersions,
packagesDirectory.FullName,
details));
}
}
// Splits a NuGet package file name into its package id and version. The file name shape is
// "<id>.<version>.nupkg", where <id> may itself contain dots (e.g. "Aspire.Hosting.Azure.Storage")
// and <version> is a strict SemVer that always begins with a numeric major component. Example:
// "Aspire.Hosting.Azure.Storage.13.4.0-preview.1.25366.3.nupkg"
// -> id = "Aspire.Hosting.Azure.Storage", version = "13.4.0-preview.1.25366.3"
// The first '.'-delimited segment that begins with a digit marks the start of the version, because
// package-id segments never start with a digit. Returns false (and the file is ignored by the
// guardrail) when no split yields a strict-SemVer version.
internal static bool TryParseNupkgFileName(string fileName, out string id, out string version)
{
id = string.Empty;
version = string.Empty;
const string suffix = ".nupkg";
if (!fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var stem = fileName[..^suffix.Length];
var parts = stem.Split('.');
for (var i = 1; i < parts.Length; i++)
{
if (parts[i].Length == 0 || !char.IsDigit(parts[i][0]))
{
continue;
}
var candidateVersion = string.Join('.', parts[i..]);
if (SemVersion.TryParse(candidateVersion, SemVersionStyles.Strict, out _))
{
id = string.Join('.', parts[..i]);
version = candidateVersion;
return true;
}
}
return false;
}
}