From 4b1eaad625b6b39b67cd37cca1a0578dc2ee973b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 13:25:22 +1000 Subject: [PATCH 1/6] Route staging-identity CLI to its darc feed regardless of version shape The synthesized `staging` package channel derived its feed from the CLI build's version shape: a prerelease-shaped staging build (e.g. 13.4.0-preview.1.26280.6) routed Aspire.* to the shared dnceng/dotnet9 daily feed instead of its SHA-specific darc-pub-microsoft-aspire- feed. C# apphosts masked this because the darc feed is baked into their nuget.config, but polyglot (TypeScript) apphosts resolve solely through the channel's feed, so `aspire add ` offered the wrong versions. Decouple feed provenance (identity) from version filtering (quality): - Add `ShouldUseSharedStagingFeed(...)`: a staging-identity CLI always uses its own darc feed, any version shape. Override feeds and non-staging identities keep the prior quality-based routing. - Add an injectable `cliInformationalVersionProvider` constructor seam so the derived darc feed URL is deterministic and assertable in tests. - Correct the comments that incorrectly claimed darc feeds only exist for stable-shaped builds. Add tests covering prerelease-shaped staging -> darc, stable-shaped staging -> darc, and override-wins. The prerelease repro fails before the fix (resolves dotnet9) and passes after (resolves darc). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackagingService.cs | 124 +++++++++++++----- .../Packaging/PackagingServiceTests.cs | 108 +++++++++++++++ 2 files changed, 200 insertions(+), 32 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index dc229e4cef1..2325df3b620 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -26,9 +26,10 @@ internal interface IPackagingService /// /// On a CLI whose baked AspireCliChannel identity is daily, local, or /// pr-<N>, there is no deterministic way to produce a real staging feed: - /// the SHA-specific darc feed (darc-pub-microsoft-aspire-<hash>) only exists - /// for stable release branch builds, and falling back to the shared daily feed silently - /// resolves daily packages instead of staging ones. To avoid that downgrade + /// those identities are not officially published release-branch builds, so no SHA-specific + /// darc feed (darc-pub-microsoft-aspire-<hash>) carries their packages, and + /// falling back to the shared daily feed silently resolves daily packages instead of staging + /// ones. To avoid that downgrade /// (see ), the service refuses /// to fabricate a staging channel from those identities unless the caller has set /// overrideStagingFeed or enabled the staging feature flag. @@ -55,6 +56,12 @@ internal class PackagingService : IPackagingService // current Aspire.Cli assembly's InformationalVersion; tests inject a deterministic value // because the version baked into the test-host assembly varies by build configuration. private readonly Func _isStableShapedCliVersion; + // Provides the running CLI's AssemblyInformationalVersion (which carries the + + // build metadata used to derive the SHA-specific darc-pub-microsoft-aspire- 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 _cliInformationalVersionProvider; // Cached result of the staging-channel availability check. The inputs (CLI identity, // overrideStagingFeed, StagingChannelEnabled feature) are effectively static for the @@ -70,7 +77,8 @@ public PackagingService( IConfiguration configuration, ILogger logger, Func? processPathProvider = null, - Func? isStableShapedCliVersion = null) + Func? isStableShapedCliVersion = null, + Func? cliInformationalVersionProvider = null) { _executionContext = executionContext; _nuGetPackageCache = nuGetPackageCache; @@ -79,6 +87,7 @@ public PackagingService( _logger = logger; _processPathProvider = processPathProvider ?? (() => Environment.ProcessPath); _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionFromAssembly; + _cliInformationalVersionProvider = cliInformationalVersionProvider ?? GetCliInformationalVersionFromAssembly; _stagingUnavailableReasonCache = new Lazy(ComputeStagingChannelUnavailableReason); } @@ -140,24 +149,23 @@ public Task> GetChannelsAsync(CancellationToken canc var stagingFeatureEnabled = _features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false); if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel) { - // Default quality selection rules (per staging entry point): + // 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- 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): - // depends on the CLI build's version shape. + // follows the CLI build's version shape so the eligible version window matches the + // packages the build actually shipped. // * Stable-shaped (e.g. "13.4.0", produced during release stabilization when - // StabilizePackageVersion=true) → Stable. The shared dotnet9 daily feed only - // carries prerelease-tagged 13.4.0-preview.* builds, so defaulting to Both - // would route Aspire.* to dotnet9 and fail to resolve the just-shipped - // stable-shaped packages — the bug from - // https://github.com/microsoft/aspire/issues/17527. Routing to Stable selects - // the SHA-derived darc-pub-microsoft-aspire- feed, where the - // stabilizing build's packages actually live. - // * Prerelease-shaped (e.g. "13.4.0-preview.1.123") → Both. SHA-specific darc - // feeds are only created for stable release-branch builds, so prerelease CLIs - // must use the shared daily feed; the historical Both default is correct. + // StabilizePackageVersion=true) → Stable, so resolution prefers the stable-shaped + // packages on the darc feed (the #17527 scenario). + // * Prerelease-shaped (e.g. "13.4.0-preview.1.123") → Both, so prerelease-tagged + // packages on the darc feed remain eligible. PackageChannelQuality defaultQuality; if (stagingIdentityChannel) { @@ -165,8 +173,8 @@ public Task> GetChannelsAsync(CancellationToken canc // quality MUST follow the CLI build's version shape regardless of how synthesis // was triggered. `init` and many other commands pass requestedChannelName=staging // when identity is staging, so checking `stagingChannelRequested` first would - // short-circuit this path and re-introduce the #17527 misroute on stabilizing - // builds. + // short-circuit this path and re-introduce the #17527 version-filtering mismatch on + // stabilizing builds. defaultQuality = _isStableShapedCliVersion() ? PackageChannelQuality.Stable : PackageChannelQuality.Both; @@ -279,6 +287,24 @@ private static bool IsStableShapedCliVersionFromAssembly() } } + // Reads the running CLI assembly's AssemblyInformationalVersion, which carries the + + // build metadata used to derive the SHA-specific darc-pub-microsoft-aspire- staging feed. + // Returns null on any error so callers degrade gracefully (no derived feed) rather than throwing. + private static string? GetCliInformationalVersionFromAssembly() + { + try + { + return Assembly.GetExecutingAssembly() + .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion; + } + catch + { + return null; + } + } + private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality) { // Refuse to synthesize a staging channel on CLI identities that cannot produce a real @@ -300,12 +326,11 @@ private static bool IsStableShapedCliVersionFromAssembly() var stagingQuality = GetStagingQuality(defaultQuality); var hasExplicitFeedOverride = !string.IsNullOrEmpty(_configuration[OverrideStagingFeedConfigKey]); - // When quality is Prerelease or Both and no explicit feed override is set, - // use the shared daily feed instead of the SHA-specific feed. SHA-specific - // darc-pub-* feeds are only created for stable-quality builds, so a non-Stable - // quality without an explicit feed override can only work with the shared feed. - var useSharedFeed = !hasExplicitFeedOverride && - stagingQuality is not PackageChannelQuality.Stable; + // 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 useSharedFeed = ShouldUseSharedStagingFeed(hasExplicitFeedOverride, stagingQuality, _executionContext.IdentityChannel); var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) @@ -340,6 +365,42 @@ private static bool IsStableShapedCliVersionFromAssembly() /// 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- 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- 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()) @@ -395,19 +456,18 @@ private bool IsStagingChannelSynthesisAllowed() // Invalid URL, fall through to default behavior } - // Use the shared daily feed when builds aren't marked stable + // 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"; } - // 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() - .FirstOrDefault()?.InformationalVersion; + // Derive the SHA-specific staging feed from the CLI's own commit hash, carried in the + // AssemblyInformationalVersion build metadata after '+'. Example informational version: + // 13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12 + // yields the feed: + // https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json + var informationalVersion = _cliInformationalVersionProvider(); if (informationalVersion is null) { diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 1780f14cf3e..ecb2d9e5607 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -111,6 +111,114 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); } + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingPrereleaseShaped_RoutesAspirePackagesToDarcFeed() + { + // Reproduces the C# vs polyglot divergence: a staging-identity CLI with a prerelease-shaped + // version (e.g. "13.4.0-preview.1.26280.6") is still an officially published release-branch + // build, so Aspire.* must resolve from its own SHA-specific darc-pub-microsoft-aspire- + // feed — NOT the shared dnceng/dotnet9 daily feed (which only carries main-branch daily + // packages). Before the fix, useSharedFeed was derived from the version shape (Both quality -> + // shared daily feed), which is what broke `aspire add` for TypeScript apphosts while C# + // apphosts (with the darc feed baked into nuget.config) resolved correctly. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + // No overrideStagingFeed configured, so the real darc-vs-shared-daily routing is exercised. + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + Assert.DoesNotContain("dotnet9", aspireMapping.Source); + + // The darc feed needs an isolated global packages folder, and it carries exactly the build's + // matching packages, so no CLI-version pin is applied. + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + Assert.Null(stagingChannel.PinnedVersion); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingStableShaped_RoutesAspirePackagesToDarcFeed() + { + // Regression guard for https://github.com/microsoft/aspire/issues/17527: a stable-shaped + // staging CLI ("13.4.0") must resolve Aspire.* from its SHA-specific darc feed with Stable + // quality (version filtering). The fix keeps this behavior while also covering the + // prerelease-shaped case above. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => true, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingWithOverrideFeed_UsesOverrideFeed() + { + // An explicit overrideStagingFeed always wins over identity-based darc derivation. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + configuration, + NullLogger.Instance, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal("https://example.com/nuget/v3/index.json", aspireMapping.Source); + } + [Fact] public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() { From 8d53b7f4f8bb7a5e0901472be608d392cddc05b1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 13:50:48 +1000 Subject: [PATCH 2/6] Strengthen staging feed-routing tests and warn on underivable feed Adds a decision-table theory across PR/daily/staging/stable channel configurations, drops the override-feed crutch from the staging-identity tests so they assert the real darc feed via an injected version seam, adds coverage for the underivable-feed warning path, and adds symmetry asserts for the stable-shaped staging case. Also logs a one-time warning in CreateStagingChannel when a staging channel is permitted but no staging feed URL can be derived, so the channel is omitted visibly instead of silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/PackagingService.cs | 14 ++ .../Packaging/PackagingServiceTests.cs | 190 +++++++++++++++--- 2 files changed, 174 insertions(+), 30 deletions(-) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 2325df3b620..43ce14a24f4 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -98,6 +98,7 @@ public PackagingService( // a project's aspire.config.json pins `channel: staging` on a daily/local CLI. private int _stagingRefusalLogged; private int _stagingResolutionLogged; + private int _stagingFeedDerivationFailedLogged; public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { @@ -335,6 +336,19 @@ private static bool IsStableShapedCliVersionFromAssembly() 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 '+' 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).", + _executionContext.IdentityChannel); + } return null; } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index ecb2d9e5607..ed0c6ffc802 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Xml.Linq; @@ -90,25 +91,33 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli // shared dotnet9 daily feed only carries prerelease-tagged 13.4.0-preview.* packages, // so a stabilizing staging CLI must route Aspire.* to the SHA-derived darc-pub-aspire- // feed instead — which requires defaulting the synthesized staging channel quality to - // Stable (so useSharedFeed in CreateStagingChannel resolves false). + // Stable (so useSharedFeed in CreateStagingChannel resolves false). No overrideStagingFeed + // is set: the injected informational version makes the darc derivation deterministic so the + // test exercises (and asserts) the real SHA-feed routing rather than an override crutch. using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" - }) - .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => true); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => true, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); } [Fact] @@ -185,6 +194,11 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingStableShaped_Rout Assert.Equal( "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", aspireMapping.Source); + + // Same darc-feed invariants as the prerelease-shaped case: isolated global packages folder + // and no CLI-version pin (the SHA feed already carries exactly the build's packages). + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + Assert.Null(stagingChannel.PinnedVersion); } [Fact] @@ -219,6 +233,124 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingWithOverrideFeed_ Assert.Equal("https://example.com/nuget/v3/index.json", aspireMapping.Source); } + [Fact] + public async Task GetChannelsAsync_WhenStagingIdentityCannotDeriveFeedUrl_OmitsChannelAndWarns() + { + // A staging-identity CLI whose informational version carries no '+' build metadata + // (e.g. an unstamped local/dev build) cannot derive its SHA-specific darc feed, and there is + // no override feed. Synthesis was permitted by the identity gate, so the only safe outcome is + // to omit the staging channel and surface a warning — silently routing to the shared daily + // feed would resolve the wrong (main-branch) packages, which is the bug this PR fixes. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + logger, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6"); // no '+' build metadata + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + // Synthesis was allowed, so the unavailable-reason API has nothing to report — the warning + // is the only diagnostic for this edge case. + Assert.Null(packagingService.GetStagingChannelUnavailableReason()); + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("staging feed URL")); + } + + public enum ExpectedStagingFeed + { + Absent, + Darc, + Shared, + Override, + } + + // Locks the full ShouldUseSharedStagingFeed decision table in one place: feed PROVENANCE is + // identity-driven (staging identity and the Stable-quality feature-flag path -> SHA-specific + // darc feed), while a non-staging identity that opts into staging with Both quality keeps the + // shared dotnet9 daily feed, an explicit override always wins, and an identity with no staging + // opt-in synthesizes no channel at all. + [Theory] + [InlineData(PackageChannelNames.Staging, false, false, false, null, ExpectedStagingFeed.Darc)] // staging identity, prerelease-shaped + [InlineData(PackageChannelNames.Staging, true, false, false, null, ExpectedStagingFeed.Darc)] // staging identity, stable-shaped + [InlineData(PackageChannelNames.Staging, false, false, false, "https://example.com/o/v3/index.json", ExpectedStagingFeed.Override)] // override always wins + [InlineData(PackageChannelNames.Stable, false, false, true, null, ExpectedStagingFeed.Shared)] // stable identity + config channel=staging => Both => shared + [InlineData(PackageChannelNames.Stable, false, true, false, null, ExpectedStagingFeed.Darc)] // stable identity + feature flag only => Stable => darc + [InlineData(PackageChannelNames.Daily, false, true, false, null, ExpectedStagingFeed.Darc)] // daily identity + feature flag only => Stable => darc + [InlineData(PackageChannelNames.Local, false, false, false, null, ExpectedStagingFeed.Absent)] // local identity, no opt-in => no channel + public async Task GetChannelsAsync_StagingFeedRoutingDecisionTable( + string identityChannel, + bool isStableShaped, + bool featureEnabled, + bool configChannelStaging, + string? overrideFeed, + ExpectedStagingFeed expected) + { + const string DarcUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json"; + const string SharedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: identityChannel); + + var settings = new Dictionary(); + if (configChannelStaging) + { + settings["channel"] = PackageChannelNames.Staging; + } + if (overrideFeed is not null) + { + settings[PackagingService.OverrideStagingFeedConfigKey] = overrideFeed; + } + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var features = new TestFeatures(); + if (featureEnabled) + { + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + } + + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + features, + configuration, + NullLogger.Instance, + isStableShapedCliVersion: () => isStableShaped, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.SingleOrDefault(c => c.Name == PackageChannelNames.Staging); + + if (expected == ExpectedStagingFeed.Absent) + { + Assert.Null(stagingChannel); + return; + } + + Assert.NotNull(stagingChannel); + var aspireSource = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source; + var expectedSource = expected switch + { + ExpectedStagingFeed.Darc => DarcUrl, + ExpectedStagingFeed.Shared => SharedUrl, + ExpectedStagingFeed.Override => overrideFeed, + _ => throw new InvalidOperationException($"Unexpected expectation: {expected}"), + }; + Assert.Equal(expectedSource, aspireSource); + } + [Fact] public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() { @@ -381,9 +513,11 @@ public async Task GetChannelsAsync_WhenChannelStagingRequestedOnNonReleaseIdenti public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithFeatureFlag_IncludesStagingChannel() { // Back-compat: the StagingChannelEnabled feature flag is an explicit developer/test opt-in - // and continues to bypass the identity gating. Without an override feed the SHA-specific - // path needs an AssemblyInformationalVersion to resolve, which is not guaranteed in test - // hosts, so we also supply overrideStagingFeed to make the test deterministic. + // and continues to bypass the identity gating. The feature-flag-only path defaults the + // synthesized channel quality to Stable, so a non-staging identity routes Aspire.* to the + // SHA-specific darc feed (not the shared daily feed). The informational version is injected + // so the darc derivation is deterministic — no overrideStagingFeed crutch is needed, which + // lets the assertions below isolate the feature-flag gate AND the real feed routing. using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -393,30 +527,26 @@ public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithFeat var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/staging/v3/index.json" - }) - .Build(); - - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + features, + new ConfigurationBuilder().Build(), + NullLogger.Instance, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); - Assert.Contains(PackageChannelNames.Staging, channels.Select(c => c.Name)); + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); Assert.Null(packagingService.GetStagingChannelUnavailableReason()); - // Isolate the feature-flag gate itself: IsStagingChannelSynthesisAllowed short-circuits on - // overrideStagingFeed before the feature flag is ever checked, so the assertions above - // would still pass if the feature-flag branch were removed. Build a second service whose - // only opt-in is the StagingChannelEnabled feature flag (no overrideStagingFeed) and - // assert that the gate alone reports the channel as available. We deliberately do not - // call GetChannelsAsync() here because the full channel-creation path requires an - // AssemblyInformationalVersion that is not guaranteed in test hosts. - var featureFlagOnlyConfig = new ConfigurationBuilder().Build(); - var featureFlagOnlyService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, featureFlagOnlyConfig, NullLogger.Instance); - Assert.Null(featureFlagOnlyService.GetStagingChannelUnavailableReason()); + // Feature-flag-only opt-in => Stable quality => darc feed (the gate alone, with no + // overrideStagingFeed, must both permit synthesis and route to the SHA feed). + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); } /// From 38aa1eb35caf86493714be8dbc9ee1d7cc9d9ba4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 14:09:38 +1000 Subject: [PATCH 3/6] Add diagnostic overrides to validate staging feed routing locally Adds two PackagingService-scoped diagnostic config overrides so a locally built CLI (baked identity 'local', unstamped version) can simulate a staging build and validate end-to-end that 'aspire add' resolves Aspire.* from the correct SHA-specific darc-pub-microsoft-aspire- feed: - overrideCliIdentityChannel: forces the identity used for staging-feed routing decisions only (validated via IdentityChannelReader.IsValidChannel; invalid values ignored). Does not change the global identity used for hive/packages-directory lookups, keeping blast radius limited. - overrideCliInformationalVersion: forces the version that both the SHA derivation and the stable-shape/quality predicate read. All staging-feed decision points now route through GetEffectiveIdentityChannel. A one-time warning is emitted whenever either override is active. Adds docs/cli-staging-validation.md with the local-validation recipe and nine PackagingServiceTests covering the override permutations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/cli-staging-validation.md | 79 ++++++ src/Aspire.Cli/Packaging/PackagingService.cs | 121 ++++++++- .../Packaging/PackagingServiceTests.cs | 234 ++++++++++++++++++ 3 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 docs/cli-staging-validation.md diff --git a/docs/cli-staging-validation.md b/docs/cli-staging-validation.md new file mode 100644 index 00000000000..6d778aa15d6 --- /dev/null +++ b/docs/cli-staging-validation.md @@ -0,0 +1,79 @@ +# Validating staging feed routing with a local CLI build + +This document describes how to make a locally built Aspire CLI resolve `Aspire.*` +packages exactly the way an official **staging** (or **stable**) build would, so the +staging feed-routing behavior can be validated end-to-end without an official build. + +## Background + +A staging-identity CLI is an official release-branch build whose own commit always has +a SHA-specific `darc-pub-microsoft-aspire-` feed carrying its matching packages +(prerelease-shaped `13.4.0-preview.*` and stable-shaped `13.4.0` alike). Feed +**provenance** is decided by the CLI's baked build **identity** (`AspireCliChannel`), +while version **filtering** (the channel quality) is decided by the CLI's **version +shape**. See `PackagingService.ShouldUseSharedStagingFeed`. + +A locally built CLI bakes a `local` identity and an unstamped informational version, so +it never synthesizes a staging channel and never derives a darc feed. The two diagnostic +overrides below let you simulate the staging path locally. + +## The two diagnostic overrides + +Both are read by `PackagingService` only (their blast radius is limited to staging +feed-routing decisions — they do **not** change the global identity used for hive or +package-directory lookups): + +| Config key | Purpose | +| --- | --- | +| `overrideCliIdentityChannel` | Forces the identity used for staging-feed routing decisions. Must be a valid channel (`stable`, `staging`, `daily`, `local`, or `pr-`); invalid values are ignored and the real identity is used. | +| `overrideCliInformationalVersion` | Forces the informational version that both the SHA-derivation provider and the version-shape (quality) predicate read. The part after `+` (truncated to 8 chars) builds the darc URL; the version part determines stable-vs-prerelease shape. | + +**Both overrides are required** to reach the darc path from a local build: + +- Identity override alone → the SHA is still unstamped, so the darc URL can't be derived. +- Version override alone → the identity stays `local`, so routing never selects the darc feed. + +When either override is set, the CLI emits a one-time warning so an overridden +identity/feed can't silently resolve packages on a normal invocation. + +## Recipe + +1. Build the CLI locally: + + ```bash + ./build.sh --build /p:SkipNativeBuild=true + ``` + +2. In the apphost directory, set `channel: staging` in `aspire.config.json` (this is what + `aspire add` filters the synthesized channels to): + + ```json + { + "channel": "staging" + } + ``` + +3. Set the two overrides (environment variables are the simplest; they are read + case-insensitively with no prefix): + + ```bash + export overrideCliIdentityChannel=staging + export overrideCliInformationalVersion=13.4.0-preview.1.26280.6+ + ``` + + Use a real release-branch build commit hash so the derived feed actually exists if you + intend to restore; any 8+ char hex suffix works for inspecting the resolved feed URL. + +4. Run `aspire add` with debug logging and confirm the resolved darc feed: + + ```bash + aspire add foundry --debug + ``` + + The logs should show the staging channel resolving `Aspire*` to + `.../darc-pub-microsoft-aspire-/...` rather than the shared + `dnceng/.../dotnet9` daily feed. + +To simulate a **stable**-shaped staging build, use a stable-shaped version override +(e.g. `13.4.0+`); the channel quality becomes `Stable` while the feed +stays the darc feed. diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 43ce14a24f4..37b43863728 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Acquisition; using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; @@ -45,6 +46,22 @@ internal class PackagingService : IPackagingService // 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- + // 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 that the SHA + // derivation and version-shape (quality) checks read, + // e.g. `13.4.0-preview.1.26280.6+`. + internal const string OverrideCliIdentityChannelConfigKey = "overrideCliIdentityChannel"; + internal const string OverrideCliInformationalVersionConfigKey = "overrideCliInformationalVersion"; + private readonly CliExecutionContext _executionContext; private readonly INuGetPackageCache _nuGetPackageCache; private readonly IFeatures _features; @@ -86,8 +103,8 @@ public PackagingService( _configuration = configuration; _logger = logger; _processPathProvider = processPathProvider ?? (() => Environment.ProcessPath); - _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionFromAssembly; - _cliInformationalVersionProvider = cliInformationalVersionProvider ?? GetCliInformationalVersionFromAssembly; + _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionDefault; + _cliInformationalVersionProvider = cliInformationalVersionProvider ?? GetCliInformationalVersionDefault; _stagingUnavailableReasonCache = new Lazy(ComputeStagingChannelUnavailableReason); } @@ -99,9 +116,15 @@ public PackagingService( private int _stagingRefusalLogged; private int _stagingResolutionLogged; private int _stagingFeedDerivationFailedLogged; + private int _stagingDiagnosticOverrideLogged; public Task> 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 defaultChannel = PackageChannel.CreateImplicitChannel(_nuGetPackageCache, _features, _logger); var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[] @@ -146,7 +169,7 @@ public Task> GetChannelsAsync(CancellationToken canc // 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(_executionContext.IdentityChannel, 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) { @@ -306,6 +329,87 @@ private static bool IsStableShapedCliVersionFromAssembly() } } + // Default version-shape predicate. Honors the overrideCliInformationalVersion diagnostic + // override (so a locally built CLI can present as stable- or prerelease-shaped for staging + // validation) before falling back to the real assembly version. + private bool IsStableShapedCliVersionDefault() + { + var overrideVersion = _configuration[OverrideCliInformationalVersionConfigKey]; + if (!string.IsNullOrEmpty(overrideVersion)) + { + // Stable-shaped == no semver prerelease tag. Strip build metadata (+) first so a + // commit hash that happens to contain '-' can't be misread as a prerelease tag. Example: + // "13.4.0-preview.1.26280.6+abcd-ef12" -> version part "13.4.0-preview.1.26280.6" -> prerelease + // "13.4.0+abcd-ef12" -> version part "13.4.0" -> stable + return !StripBuildMetadata(overrideVersion).Contains('-'); + } + + return IsStableShapedCliVersionFromAssembly(); + } + + // 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 real assembly informational version. + private string? GetCliInformationalVersionDefault() + { + var overrideVersion = _configuration[OverrideCliInformationalVersionConfigKey]; + if (!string.IsNullOrEmpty(overrideVersion)) + { + return overrideVersion; + } + + return GetCliInformationalVersionFromAssembly(); + } + + 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 @@ -331,7 +435,8 @@ private static bool IsStableShapedCliVersionFromAssembly() // 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 useSharedFeed = ShouldUseSharedStagingFeed(hasExplicitFeedOverride, stagingQuality, _executionContext.IdentityChannel); + var effectiveIdentityChannel = GetEffectiveIdentityChannel(); + var useSharedFeed = ShouldUseSharedStagingFeed(hasExplicitFeedOverride, stagingQuality, effectiveIdentityChannel); var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) @@ -347,7 +452,7 @@ private static bool IsStableShapedCliVersionFromAssembly() { _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).", - _executionContext.IdentityChannel); + effectiveIdentityChannel); } return null; } @@ -425,7 +530,7 @@ private static bool ShouldUseSharedStagingFeed(bool hasExplicitFeedOverride, Pac return string.Format( CultureInfo.CurrentCulture, PackagingStrings.StagingChannelUnavailableOnDailyCli, - _executionContext.IdentityChannel); + GetEffectiveIdentityChannel()); } private bool IsStagingChannelSynthesisAllowed() @@ -452,8 +557,8 @@ private bool IsStagingChannelSynthesisAllowed() // For daily, local, and pr- 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(_executionContext.IdentityChannel, PackageChannelNames.Stable, StringComparisons.ChannelName) - || string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName); + return string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Stable, StringComparisons.ChannelName) + || string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Staging, StringComparisons.ChannelName); } private string? GetStagingFeedUrl(bool useSharedFeed) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index ed0c6ffc802..405d4ac9ae8 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -351,6 +351,240 @@ public async Task GetChannelsAsync_StagingFeedRoutingDecisionTable( Assert.Equal(expectedSource, aspireSource); } + // The following tests exercise the diagnostic override mechanism (overrideCliIdentityChannel + + // overrideCliInformationalVersion) end-to-end through the REAL config-reading default providers + // (the seams are intentionally NOT injected), which is exactly the local-validation recipe in + // docs/cli-staging-validation.md. A locally built CLI bakes a 'local' identity, so without the + // overrides these scenarios would never synthesize a staging channel at all. + + [Fact] + public async Task GetChannelsAsync_WhenIdentityOverrideAndVersionOverrideSet_RoutesAspirePackagesToDarcFeed() + { + // Full local-validation recipe: a 'local' identity CLI is told (via config overrides) to behave + // like a prerelease-shaped staging build. Both overrides are required — the identity override + // makes ShouldUseSharedStagingFeed pick the darc feed, and the version override supplies the + // '+' the darc URL is derived from. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + Assert.DoesNotContain("dotnet9", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenVersionOverrideIsStableShaped_DefaultsToStableQuality() + { + // A stable-shaped (no semver prerelease tag) version override drives the quality predicate to + // Stable, mirroring how an official stable-shaped staging build is filtered. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityOverrideIsInvalid_FallsBackToRealIdentity() + { + // An unrecognized identity override (rejected by IdentityChannelReader.IsValidChannel) is + // ignored and the real 'local' identity is used, so no staging channel is synthesized despite + // the version override being present. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = "not-a-real-channel", + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + } + + [Fact] + public async Task GetChannelsAsync_WhenOverrideStagingFeedSet_WinsOverVersionOverrideDerivation() + { + // overrideStagingFeed is the most powerful escape hatch and must win over the SHA-derived darc + // URL even when the diagnostic version override is also present. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + const string OverrideFeed = "https://example.com/override/v3/index.json"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + [PackagingService.OverrideStagingFeedConfigKey] = OverrideFeed, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(OverrideFeed, Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingDiagnosticOverridesActive_EmitsWarning() + { + // Any normal CLI invocation that has the diagnostic overrides set must leave a trace in the + // logs so an overridden identity/feed can't silently resolve Aspire.* packages. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger); + + await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active")); + } + + [Fact] + public async Task GetChannelsAsync_WhenOnlyVersionOverrideSet_WarnsButSynthesizesNoStagingChannel() + { + // Only the version override is set, so the identity stays 'local' and no staging channel is + // synthesized. The warning must still fire — the override is active even though it had no + // routing effect, and a silent no-op would hide a misconfiguration. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active")); + } + + [Theory] + [InlineData("13.4.0+abcd-ef1234567890", true)] // hyphen only in build metadata => stable-shaped + [InlineData("13.4.0-preview.1.26280.6+abcd-ef1234567890", false)] // semver prerelease tag => prerelease-shaped + public async Task GetChannelsAsync_VersionOverrideStableShapeIgnoresBuildMetadataHyphens(string overrideVersion, bool expectStableQuality) + { + // StripBuildMetadata removes the '+' before the prerelease-tag check, so a commit hash + // containing '-' must not be misread as a semver prerelease tag. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = overrideVersion, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(expectStableQuality ? PackageChannelQuality.Stable : PackageChannelQuality.Both, stagingChannel.Quality); + } + + [Fact] + public void GetStagingChannelUnavailableReason_WhenIdentityOverrideIsStaging_ReturnsNull() + { + // The unavailable-reason check (cached via Lazy) must also honor the identity override, so a + // local CLI with overrideCliIdentityChannel=staging reports staging as available. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + Assert.Null(packagingService.GetStagingChannelUnavailableReason()); + } + [Fact] public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() { From e09d25326a94aaa037a12da06ef837dd9af4ed66 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 17:45:56 +1000 Subject: [PATCH 4/6] Add debug-staging/debug-stable scripts to simulate release-branch feed routing Add eng/scripts/debug-staging.{sh,ps1} and debug-stable.{sh,ps1} (plus the shared debug-aspire-channel core) that make an easy-to-get build (an installed PR build or a local build) resolve Aspire.* packages exactly like an official staging or stable release-branch build, so the feed-routing fix can be validated end-to-end. Each script targets identity 'staging' and the SHA-specific darc-pub-microsoft-aspire- feed, differing only in version shape/quality (staging => prerelease/Both, #17744; stable => stable/Stable, #17527). Modes: - default: one-shot 'aspire add --debug' that asserts the darc feed resolves. - --print-env / -PrintEnv: emit export/$env lines to apply to the current shell. - --shell / -Shell: interactive subshell with overrides applied and the target CLI first on PATH, for a full 'aspire new'/'add'/run flow; overrides vanish on exit. Extend docs/cli-staging-validation.md with the helper-script usage, the interactive PR-build recipe, and a validation matrix; link it from docs/contributing.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/cli-staging-validation.md | 67 +++++++ docs/contributing.md | 2 + eng/scripts/debug-aspire-channel.ps1 | 228 +++++++++++++++++++++ eng/scripts/debug-aspire-channel.sh | 288 +++++++++++++++++++++++++++ eng/scripts/debug-stable.ps1 | 63 ++++++ eng/scripts/debug-stable.sh | 16 ++ eng/scripts/debug-staging.ps1 | 63 ++++++ eng/scripts/debug-staging.sh | 16 ++ 8 files changed, 743 insertions(+) create mode 100644 eng/scripts/debug-aspire-channel.ps1 create mode 100755 eng/scripts/debug-aspire-channel.sh create mode 100644 eng/scripts/debug-stable.ps1 create mode 100755 eng/scripts/debug-stable.sh create mode 100644 eng/scripts/debug-staging.ps1 create mode 100755 eng/scripts/debug-staging.sh diff --git a/docs/cli-staging-validation.md b/docs/cli-staging-validation.md index 6d778aa15d6..bd59635dc2c 100644 --- a/docs/cli-staging-validation.md +++ b/docs/cli-staging-validation.md @@ -77,3 +77,70 @@ identity/feed can't silently resolve packages on a normal invocation. To simulate a **stable**-shaped staging build, use a stable-shaped version override (e.g. `13.4.0+`); the channel quality becomes `Stable` while the feed stays the darc feed. + +## Helper scripts + +`eng/scripts/debug-staging.{sh,ps1}` and `eng/scripts/debug-stable.{sh,ps1}` wrap the +recipe above. Both target identity `staging` and expect the **same** darc feed; they +differ only in version shape/quality: + +| Script | Version shape | Expected quality | Scenario | +| --- | --- | --- | --- | +| `debug-staging` | prerelease (`13.4.0-preview.*`) | `Both` | [#17744](https://github.com/microsoft/aspire/issues/17744) — the bug this PR fixes | +| `debug-stable` | stable (`13.4.0`) | `Stable` | [#17527](https://github.com/microsoft/aspire/issues/17527) — stable-shaped release build | + +Each script computes the expected `darc-pub-microsoft-aspire-` feed and supports +three modes: + +- **Validate (default):** runs `aspire add --debug` in a throwaway directory and + asserts the darc feed appears in the resolution log. Exits non-zero if it doesn't. +- **`--print-env` / `-PrintEnv`:** emits `export`/`$env:` lines you apply to your current + shell. Every subsequent `aspire` command then behaves like the simulated build. +- **`--shell` / `-Shell`:** opens an interactive subshell with the overrides applied and + the target CLI first on `PATH`. Exiting the subshell restores normal behavior. + +Common flags: `--sha ` (required, 8–40 hex), `--cli ` (CLI to drive), +`--pr ` (install that PR's full-bundle build first, then target it), `--version `. + +### Interactive validation against an installed PR build + +You don't need a local source build — the easiest carrier is an installed **PR build**, +which is a real full-bundle `~/.aspire` install. Install it, then make it behave like a +staging build for a full `aspire new` / `aspire add` / run flow: + +```bash +# 1. Install the PR's full-bundle build. +./eng/scripts/get-aspire-cli-pr.sh 17743 + +# 2a. Apply staging overrides to the CURRENT shell (every aspire command is staging-flavored): +eval "$(./eng/scripts/debug-stable.sh --sha --print-env)" +aspire new # behaves like the simulated staging build +aspire add foundry +# revert when done: +unset channel overrideCliIdentityChannel overrideCliInformationalVersion + +# 2b. ...or get a throwaway subshell instead (overrides vanish on 'exit'): +./eng/scripts/debug-stable.sh --pr 17743 --sha --shell +``` + +PowerShell is identical with the `.ps1` siblings: + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 17743 +./eng/scripts/debug-stable.ps1 -Sha -PrintEnv | Invoke-Expression +# ...or: +./eng/scripts/debug-stable.ps1 -Pr 17743 -Sha -Shell +``` + +The overrides are scoped to `PackagingService` feed routing and only ever live in the +shell/subshell environment, so nothing is written to global or per-project config. + +## Validation matrix + +| Identity | Version shape | Expected feed | Expected quality | +| --- | --- | --- | --- | +| `staging` | prerelease | `darc-pub-microsoft-aspire-` | `Both` | +| `staging` | stable | `darc-pub-microsoft-aspire-` | `Stable` | +| `daily` | any | shared `dnceng/.../dotnet9` daily feed | `Both` | +| `local` / `pr-` | any | local/PR hive + implicit (no staging synthesis) | n/a | +| `stable` | stable | nuget.org | `Stable` | diff --git a/docs/contributing.md b/docs/contributing.md index ad738263df9..362e8a8443c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -94,6 +94,8 @@ dotnet test --filter-not-trait "quarantined=true" To test changes from a specific pull request locally, see [dogfooding-pull-requests.md](/docs/dogfooding-pull-requests.md) for instructions on installing Aspire CLI and NuGet packages built by that PR's CI run. +To validate how the CLI resolves `Aspire.*` packages for **staging** and **stable** release-branch builds (including making an installed PR build behave like a staging build), see [cli-staging-validation.md](/docs/cli-staging-validation.md). + ## Integrations (Formerly Components) Please check the [Aspire integrations contribution guidelines](/src/Components/README.md) if you intend to make contributions to a new or existing Aspire integration. diff --git a/eng/scripts/debug-aspire-channel.ps1 b/eng/scripts/debug-aspire-channel.ps1 new file mode 100644 index 00000000000..34ea8649ece --- /dev/null +++ b/eng/scripts/debug-aspire-channel.ps1 @@ -0,0 +1,228 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Shared implementation for debug-staging.ps1 and debug-stable.ps1. + +.DESCRIPTION + Makes an EASY-TO-GET Aspire CLI build behave like an official release-branch + staging build for validating package feed routing, WITHOUT producing a real + official build or stamping a binary locally. + + The recommended carrier is a PR build (a real, full self-extracting ~/.aspire + install) acquired with eng/scripts/get-aspire-cli-pr.ps1 . Any installed + 'aspire' (or a locally built one via -Cli) works just as well, because the + behavior is driven entirely by two diagnostic config overrides read by + PackagingService (see docs/cli-staging-validation.md): + + overrideCliIdentityChannel - forces the identity used for staging-feed + routing decisions (here: 'staging'). + overrideCliInformationalVersion - forces the informational version the SHA + derivation and version-shape (quality) + checks read, e.g. 13.4.0-preview.1.x+. + + Both flow into IConfiguration from environment variables (used here) OR from + aspire.config.json, and are scoped to staging feed routing only. A CLI run + with them set emits a one-time warning so they can never silently mis-route a + normal invocation. + + The script runs 'aspire add --debug' in a throwaway directory whose + aspire.config.json pins channel: staging, then asserts the debug log contains + Resolved 'staging' channel: feed=, quality= + The 'aspire add' step is expected to fail later (there is no real apphost + project in the scratch directory); only the feed-routing log line is validated. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Default version shapes for the 13.4 release branch. Override with -Version. +$script:DefaultStagingVersion = '13.4.0-preview.1.26280.6' +$script:DefaultStableVersion = '13.4.0' + +function Invoke-DebugChannel { + [CmdletBinding()] + param( + # 'staging' (prerelease-shaped) | 'stable' (stable-shaped) + [Parameter(Mandatory = $true)][ValidateSet('staging', 'stable')][string]$Kind, + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [string[]]$PassThrough = @() + ) + + switch ($Kind) { + 'staging' { $kindLabel = 'staging (prerelease-shaped)'; $defaultVersion = $script:DefaultStagingVersion; $expectedQuality = 'Both' } + 'stable' { $kindLabel = 'staging (stable-shaped)'; $defaultVersion = $script:DefaultStableVersion; $expectedQuality = 'Stable' } + } + + if ([string]::IsNullOrEmpty($Sha)) { + Write-Error '-Sha is required.' + return + } + + # The darc feed name is built from the first 8 chars of the commit hash, so + # require at least that many hex characters (full hashes are accepted). + if ($Sha -notmatch '^[0-9a-fA-F]{8,40}$') { + Write-Error "-Sha must be 8-40 hexadecimal characters (got '$Sha')." + return + } + + if ([string]::IsNullOrEmpty($Version)) { $Version = $defaultVersion } + + $sha8 = $Sha.Substring(0, 8).ToLowerInvariant() + $expectedFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-$sha8/nuget/v3/index.json" + $infoVersion = "$Version+$Sha" + + # -PrintEnv: emit shell-applicable env assignments and stop. CLI-agnostic on + # purpose -- the three keys drive ANY 'aspire' on PATH. Intended use: + # ./debug-staging.ps1 -Sha -PrintEnv | Invoke-Expression + if ($PrintEnv) { + Write-Output "# $kindLabel build (sha $sha8, feed darc-pub-microsoft-aspire-$sha8, quality $expectedQuality)." + Write-Output "# Apply to your current PowerShell session, then run aspire commands normally." + Write-Output "`$env:channel = 'staging'" + Write-Output "`$env:overrideCliIdentityChannel = '$Identity'" + Write-Output "`$env:overrideCliInformationalVersion = '$infoVersion'" + Write-Output "# To revert:" + Write-Output "# Remove-Item Env:channel, Env:overrideCliIdentityChannel, Env:overrideCliInformationalVersion" + return + } + + $scriptDir = Split-Path -Parent $PSCommandPath + + # Optionally install the PR build first; it becomes the default target CLI. + if (-not [string]::IsNullOrEmpty($Pr)) { + Write-Host ">> Installing PR #$Pr build via get-aspire-cli-pr.ps1 ..." + & (Join-Path $scriptDir 'get-aspire-cli-pr.ps1') $Pr + } + + if ([string]::IsNullOrEmpty($Cli)) { + $onPath = Get-Command aspire -ErrorAction SilentlyContinue + $installed = Join-Path $HOME '.aspire/bin/aspire' + if ($onPath) { + $Cli = $onPath.Source + } + elseif (Test-Path $installed) { + $Cli = $installed + } + else { + Write-Error "No aspire CLI found. Install a PR build (-Pr ), pass -Cli , or put 'aspire' on PATH." + return + } + } + if (-not (Test-Path $Cli)) { + Write-Error "CLI path '$Cli' does not exist." + return + } + # Resolve to an absolute path because the validation step runs from a scratch + # working directory, where a relative -Cli would no longer resolve. + $Cli = (Resolve-Path $Cli).Path + + Write-Host '' + Write-Host "Simulating an official $kindLabel build" + Write-Host " CLI: $Cli" + Write-Host " identity override: $Identity" + Write-Host " version override: $infoVersion" + Write-Host " expected feed: $expectedFeed" + Write-Host " expected quality: $expectedQuality" + Write-Host '' + + # -Shell: start a child PowerShell where the target CLI behaves like this build + # for every 'aspire' command. The overrides live only in the child process' + # environment, so closing it fully restores normal behavior. The CLI's directory + # is put first on PATH so a bare 'aspire' resolves to the target build. + if ($Shell) { + $cliDir = Split-Path -Parent $Cli + Write-Host '>> Launching a child PowerShell. Run aspire new, aspire add, etc.' + Write-Host " 'aspire' resolves to: $Cli" + Write-Host " Type 'exit' to leave and restore normal CLI behavior." + Write-Host '' + $env:channel = 'staging' + $env:overrideCliIdentityChannel = $Identity + $env:overrideCliInformationalVersion = $infoVersion + $env:PATH = "$cliDir$([System.IO.Path]::PathSeparator)$env:PATH" + & (Get-Process -Id $PID).Path -NoExit -NoLogo + return + } + + # Throwaway working directory pinned to channel: staging so 'aspire add' + # filters to the synthesized staging channel. No real apphost project lives + # here, so 'add' will ultimately fail after feed routing has already been + # logged -- that is expected. + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("aspire-debug-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $scratch | Out-Null + try { + @' +{ + "channel": "staging" +} +'@ | Set-Content -Path (Join-Path $scratch 'aspire.config.json') -Encoding utf8 + + $log = Join-Path $scratch 'aspire-debug.log' + Write-Host ">> Running: aspire add $Package --debug $($PassThrough -join ' ')" + Write-Host ' (feed routing is logged before the add step fails on the missing apphost)' + Write-Host '' + + # The overrides are scoped to THIS invocation only (set then removed), so + # they can't leak into the developer's other aspire commands. 'aspire add' + # is allowed to exit non-zero; success is decided by the log. + $previousChannel = $env:overrideCliIdentityChannel + $previousVersion = $env:overrideCliInformationalVersion + $previousLocation = Get-Location + try { + $env:overrideCliIdentityChannel = $Identity + $env:overrideCliInformationalVersion = $infoVersion + Set-Location $scratch + $cliArgs = @('add', $Package, '--debug') + $PassThrough + & $Cli @cliArgs *> $log + } + finally { + Set-Location $previousLocation + $env:overrideCliIdentityChannel = $previousChannel + $env:overrideCliInformationalVersion = $previousVersion + } + + $logText = Get-Content -Path $log -Raw -ErrorAction SilentlyContinue + if ($null -eq $logText) { $logText = '' } + + # Echo the resolution + override-warning lines for visibility. + Get-Content -Path $log -ErrorAction SilentlyContinue | + Where-Object { $_ -match "diagnostic overrides are active|Resolved 'staging' channel|Refusing to synthesize|Could not synthesize" } | + ForEach-Object { Write-Host $_ } + Write-Host '' + + $expectedLine = "Resolved 'staging' channel: feed=$expectedFeed" + if ($logText -notmatch [regex]::Escape($expectedLine)) { + Write-Host $logText + Write-Error "FAILED: did not resolve the expected darc feed. Expected: $expectedLine, quality=$expectedQuality" + return + } + if ($logText -notmatch [regex]::Escape("$expectedLine, quality=$expectedQuality")) { + Write-Error "FAILED: resolved the darc feed but quality was not '$expectedQuality'." + return + } + + Write-Host "PASSED: $kindLabel build resolves Aspire.* from the darc feed with quality=$expectedQuality." + Write-Host '' + Write-Host "Equivalent persistent 'config options' (drop into the apphost's aspire.config.json" + Write-Host 'to simulate this build interactively with an installed PR build):' + Write-Host '' + Write-Host @" +{ + "channel": "staging", + "overrideCliIdentityChannel": "$Identity", + "overrideCliInformationalVersion": "$infoVersion" +} +"@ + Write-Host '' + Write-Host 'Remove those override keys when you are done -- they are for local validation only.' + } + finally { + Remove-Item -Path $scratch -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/eng/scripts/debug-aspire-channel.sh b/eng/scripts/debug-aspire-channel.sh new file mode 100755 index 00000000000..d3c97819342 --- /dev/null +++ b/eng/scripts/debug-aspire-channel.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash + +# Shared implementation for debug-staging.sh and debug-stable.sh. +# +# Purpose +# ------- +# Make an EASY-TO-GET Aspire CLI build behave like an official release-branch +# **staging** build for the purpose of validating package feed routing, WITHOUT +# having to produce a real official build or stamp a binary locally. +# +# The recommended carrier is a PR build (a real, full self-extracting `~/.aspire` +# install) acquired with `eng/scripts/get-aspire-cli-pr.sh `. Any installed +# `aspire` (or a locally built one via `--cli`) works just as well, because the +# behavior is driven entirely by two diagnostic config overrides read by +# `PackagingService` (see docs/cli-staging-validation.md): +# +# overrideCliIdentityChannel - forces the identity used for staging-feed +# routing decisions (here: `staging`). +# overrideCliInformationalVersion - forces the informational version the SHA +# derivation and version-shape (quality) +# checks read, e.g. `13.4.0-preview.1.x+`. +# +# Both flow into IConfiguration from environment variables (used here) OR from +# aspire.config.json, and are scoped to staging feed routing only -- they do NOT +# change the global identity used for hive/packages directory lookups. A CLI run +# with them set emits a one-time warning so they can never silently mis-route a +# normal invocation. +# +# What this script asserts +# ------------------------ +# It runs `aspire add --debug` in a throwaway directory whose +# aspire.config.json pins `channel: staging`, then asserts the debug log contains +# Resolved 'staging' channel: feed=, quality= +# where the feed is the SHA-specific darc-pub-microsoft-aspire- +# feed. The `aspire add` step is expected to fail later (there is no real apphost +# project in the scratch directory); only the feed-routing log line is validated. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Default version shapes for the 13.4 release branch. Override with --version. +readonly DEFAULT_STAGING_VERSION="13.4.0-preview.1.26280.6" +readonly DEFAULT_STABLE_VERSION="13.4.0" + +say() { printf '%s\n' "$*"; } +say_err() { printf 'error: %s\n' "$*" >&2; } + +print_usage() { + local invoked_as="$1" + cat < [options] [-- ] + +Simulates an official ${KIND_LABEL} build and validates that the CLI resolves +Aspire.* packages from the SHA-specific darc feed for . + +Required: + --sha Commit hash of the darc feed to target (>= 8 hex chars). + Use a real release-branch build commit if you intend to + actually restore packages; any 8+ hex value is fine for + inspecting/asserting the resolved feed URL. + +Options: + --pr Install that PR's build first (via get-aspire-cli-pr.sh) + and target it. Omit to use an already-installed CLI. + --cli Path to the aspire CLI to drive. Default: 'aspire' on + PATH, else ~/.aspire/bin/aspire. + --version Override the informational version (without +). + Default for ${KIND_LABEL}: ${DEFAULT_VERSION}. + --identity Override the identity used for staging-feed routing. + Default: staging. + --package Package to use for the validation 'aspire add'. + Default: foundry. + --shell Instead of the one-shot validation, drop into an + interactive subshell where the target CLI behaves like + this ${KIND_LABEL} build for EVERY 'aspire' command + (aspire new, add, run, ...). The overrides are exported + only into that subshell; they vanish when you exit it. + --print-env Print the 'export' lines for this build to stdout so you + can apply them to your current shell, e.g. + eval "\$(${invoked_as} --sha --print-env)" + Every 'aspire' command in that shell then behaves like + this build until you 'unset' the variables (printed too). + -h, --help Show this help. + +Anything after '--' is passed through to the aspire invocation. + +Examples: + ${invoked_as} --sha 1a2b3c4d5e6f7a8b + ${invoked_as} --pr 17743 --sha 1a2b3c4d5e6f7a8b + ${invoked_as} --cli ./artifacts/bin/Aspire.Cli/Debug/net10.0/aspire --sha 1a2b3c4d + # Install a PR build and explore it interactively as a staging build: + ${invoked_as} --pr 17743 --sha 1a2b3c4d5e6f7a8b --shell +USAGE +} + +# run_debug_channel [args...] +# kind: "staging" (prerelease-shaped) | "stable" (stable-shaped) +run_debug_channel() { + local kind="$1"; shift + local invoked_as="$1"; shift + + case "$kind" in + staging) KIND_LABEL="staging (prerelease-shaped)"; DEFAULT_VERSION="$DEFAULT_STAGING_VERSION"; EXPECTED_QUALITY="Both" ;; + stable) KIND_LABEL="staging (stable-shaped)"; DEFAULT_VERSION="$DEFAULT_STABLE_VERSION"; EXPECTED_QUALITY="Stable" ;; + *) say_err "unknown kind '$kind'"; return 2 ;; + esac + + local sha="" pr="" cli_path="" version="" identity="staging" package="foundry" + local mode="validate" + local -a passthrough=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --sha) sha="${2:-}"; shift 2 ;; + --pr) pr="${2:-}"; shift 2 ;; + --cli) cli_path="${2:-}"; shift 2 ;; + --version) version="${2:-}"; shift 2 ;; + --identity) identity="${2:-}"; shift 2 ;; + --package) package="${2:-}"; shift 2 ;; + --shell) mode="shell"; shift ;; + --print-env) mode="printenv"; shift ;; + -h|--help) print_usage "$invoked_as"; return 0 ;; + --) shift; passthrough=("$@"); break ;; + *) say_err "unknown argument '$1'"; print_usage "$invoked_as" >&2; return 2 ;; + esac + done + + if [[ -z "$sha" ]]; then + say_err "--sha is required." + print_usage "$invoked_as" >&2 + return 2 + fi + + # The darc feed name is built from the first 8 chars of the commit hash, so + # require at least that many hex characters (full hashes are accepted). + if [[ ! "$sha" =~ ^[0-9a-fA-F]{8,40}$ ]]; then + say_err "--sha must be 8-40 hexadecimal characters (got '$sha')." + return 2 + fi + + [[ -n "$version" ]] || version="$DEFAULT_VERSION" + + # Lowercase the first 8 chars to match how PackagingService derives the feed. + local sha8 + sha8="$(printf '%s' "${sha:0:8}" | tr '[:upper:]' '[:lower:]')" + local expected_feed="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-${sha8}/nuget/v3/index.json" + local info_version="${version}+${sha}" + + # --print-env: emit shell-applicable export/unset lines and stop. This mode is + # CLI-agnostic on purpose -- the three keys drive ANY 'aspire' on PATH, so the + # developer can install/upgrade the carrier build independently. Intended use: + # eval "$(debug-staging.sh --sha --print-env)" + if [[ "$mode" == "printenv" ]]; then + cat <> Installing PR #${pr} build via get-aspire-cli-pr.sh ..." + "${SCRIPT_DIR}/get-aspire-cli-pr.sh" "$pr" + fi + + if [[ -z "$cli_path" ]]; then + if command -v aspire >/dev/null 2>&1; then + cli_path="$(command -v aspire)" + elif [[ -x "$HOME/.aspire/bin/aspire" ]]; then + cli_path="$HOME/.aspire/bin/aspire" + else + say_err "No aspire CLI found. Install a PR build (--pr ), pass --cli , or put 'aspire' on PATH." + return 1 + fi + fi + if [[ ! -x "$cli_path" ]]; then + say_err "CLI path '$cli_path' is not executable." + return 1 + fi + # Resolve to an absolute path because the validation step runs from a scratch + # working directory, where a relative --cli would no longer resolve. + case "$cli_path" in + /*) : ;; + *) cli_path="$(cd "$(dirname "$cli_path")" && pwd)/$(basename "$cli_path")" ;; + esac + + say "" + say "Simulating an official ${KIND_LABEL} build" + say " CLI: $cli_path" + say " identity override: $identity" + say " version override: $info_version" + say " expected feed: $expected_feed" + say " expected quality: $EXPECTED_QUALITY" + say "" + + # --shell: drop into an interactive subshell where the target CLI behaves like + # this build for EVERY 'aspire' command. The overrides live only in this child + # shell's environment, so exiting it fully restores normal behavior -- nothing + # is written to global/aspire.config.json. The target CLI's directory is put + # first on PATH so a bare 'aspire' resolves to it (handles --cli pointing at a + # local build as well as an installed PR build). + if [[ "$mode" == "shell" ]]; then + local cli_dir + cli_dir="$(dirname "$cli_path")" + say ">> Launching an interactive subshell. Run 'aspire new', 'aspire add', etc." + say " 'aspire' resolves to: $cli_path" + say " Type 'exit' to leave and restore normal CLI behavior." + say "" + channel="staging" \ + overrideCliIdentityChannel="$identity" \ + overrideCliInformationalVersion="$info_version" \ + PATH="${cli_dir}:${PATH}" \ + ASPIRE_DEBUG_BUILD_PROMPT="aspire(${kind}:${sha8})" \ + "${SHELL:-/bin/bash}" -i + return $? + fi + + # Throwaway working directory pinned to channel: staging so 'aspire add' + # filters to the synthesized staging channel. Created securely; cleaned up + # on exit. No real apphost project lives here, so 'add' will ultimately fail + # after feed routing has already been logged -- that is expected. + local scratch + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' RETURN + cat > "${scratch}/aspire.config.json" <> Running: aspire add ${package} --debug ${passthrough[*]:-}" + say " (feed routing is logged before the add step fails on the missing apphost)" + say "" + + # The overrides are scoped to THIS invocation only (no export, no persisted + # config), so they can't leak into the developer's other aspire commands. + # 'aspire add' is allowed to exit non-zero; success is decided by the log. + set +e + ( cd "$scratch" && \ + overrideCliIdentityChannel="$identity" \ + overrideCliInformationalVersion="$info_version" \ + "$cli_path" add "$package" --debug "${passthrough[@]+"${passthrough[@]}"}" ) > "$log" 2>&1 + set -e + + # Echo the resolution + override-warning lines for visibility. + grep -E "diagnostic overrides are active|Resolved 'staging' channel|Refusing to synthesize|Could not synthesize" "$log" || true + say "" + + local resolved_line + resolved_line="$(grep -F "Resolved 'staging' channel: feed=${expected_feed}" "$log" || true)" + if [[ -z "$resolved_line" ]]; then + say_err "FAILED: did not resolve the expected darc feed." + say_err "Expected: Resolved 'staging' channel: feed=${expected_feed}, quality=${EXPECTED_QUALITY}" + say_err "See full debug log for details:" + sed 's/^/ /' "$log" >&2 + return 1 + fi + + if [[ "$resolved_line" != *"quality=${EXPECTED_QUALITY}"* ]]; then + say_err "FAILED: resolved the darc feed but quality was not '${EXPECTED_QUALITY}'." + say_err " $resolved_line" + return 1 + fi + + say "PASSED: ${KIND_LABEL} build resolves Aspire.* from the darc feed with quality=${EXPECTED_QUALITY}." + say "" + say "Equivalent persistent 'config options' (drop into the apphost's aspire.config.json" + say "to simulate this build interactively with an installed PR build):" + say "" + cat <= 8 hex chars). Use a real + release-branch build commit to actually restore packages; any 8+ hex value + is fine for inspecting/asserting the resolved feed URL. + +.PARAMETER Pr + Install that PR's build first (via get-aspire-cli-pr.ps1) and target it. + Omit to use an already-installed CLI. + +.PARAMETER Cli + Path to the aspire CLI to drive. Default: 'aspire' on PATH, else + ~/.aspire/bin/aspire. + +.PARAMETER Version + Override the informational version (without +). Default: 13.4.0. + +.PARAMETER Identity + Override the identity used for staging-feed routing. Default: staging. + +.PARAMETER Package + Package to use for the validation 'aspire add'. Default: foundry. + +.PARAMETER PassThrough + Extra arguments passed through to the aspire invocation. + +.EXAMPLE + ./debug-stable.ps1 -Sha 1a2b3c4d5e6f7a8b + +.EXAMPLE + ./debug-stable.ps1 -Pr 17743 -Sha 1a2b3c4d5e6f7a8b +#> + +[CmdletBinding()] +param( + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$PassThrough = @() +) + +. (Join-Path (Split-Path -Parent $PSCommandPath) 'debug-aspire-channel.ps1') + +Invoke-DebugChannel -Kind 'stable' -Sha $Sha -Pr $Pr -Cli $Cli -Version $Version ` + -Identity $Identity -Package $Package -Shell:$Shell -PrintEnv:$PrintEnv -PassThrough $PassThrough diff --git a/eng/scripts/debug-stable.sh b/eng/scripts/debug-stable.sh new file mode 100755 index 00000000000..47f797efeb8 --- /dev/null +++ b/eng/scripts/debug-stable.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Simulate an official STABLE-shaped staging build (e.g. 13.4.0) and validate that +# the CLI resolves Aspire.* from its SHA-specific darc feed. +# +# This is the scenario from https://github.com/microsoft/aspire/issues/17527: +# a stable-shaped release-branch build still resolves from its own darc feed +# (quality=Stable), not nuget.org. +# +# See docs/cli-staging-validation.md for the full validation matrix. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/debug-aspire-channel.sh" + +run_debug_channel stable "debug-stable.sh" "$@" diff --git a/eng/scripts/debug-staging.ps1 b/eng/scripts/debug-staging.ps1 new file mode 100644 index 00000000000..d44261462f9 --- /dev/null +++ b/eng/scripts/debug-staging.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Simulate an official PRERELEASE-shaped staging build (e.g. 13.4.0-preview.*) + and validate that the CLI resolves Aspire.* from its SHA-specific darc feed. + +.DESCRIPTION + This is the scenario from https://github.com/microsoft/aspire/issues/17744: + a prerelease-shaped staging build must use the darc-pub-microsoft-aspire- + feed (quality=Both), NOT the shared daily feed. + + See docs/cli-staging-validation.md for the full validation matrix. + +.PARAMETER Sha + Commit hash of the darc feed to target (>= 8 hex chars). Use a real + release-branch build commit to actually restore packages; any 8+ hex value + is fine for inspecting/asserting the resolved feed URL. + +.PARAMETER Pr + Install that PR's build first (via get-aspire-cli-pr.ps1) and target it. + Omit to use an already-installed CLI. + +.PARAMETER Cli + Path to the aspire CLI to drive. Default: 'aspire' on PATH, else + ~/.aspire/bin/aspire. + +.PARAMETER Version + Override the informational version (without +). Default: 13.4.0-preview.1.26280.6. + +.PARAMETER Identity + Override the identity used for staging-feed routing. Default: staging. + +.PARAMETER Package + Package to use for the validation 'aspire add'. Default: foundry. + +.PARAMETER PassThrough + Extra arguments passed through to the aspire invocation. + +.EXAMPLE + ./debug-staging.ps1 -Sha 1a2b3c4d5e6f7a8b + +.EXAMPLE + ./debug-staging.ps1 -Pr 17743 -Sha 1a2b3c4d5e6f7a8b +#> + +[CmdletBinding()] +param( + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$PassThrough = @() +) + +. (Join-Path (Split-Path -Parent $PSCommandPath) 'debug-aspire-channel.ps1') + +Invoke-DebugChannel -Kind 'staging' -Sha $Sha -Pr $Pr -Cli $Cli -Version $Version ` + -Identity $Identity -Package $Package -Shell:$Shell -PrintEnv:$PrintEnv -PassThrough $PassThrough diff --git a/eng/scripts/debug-staging.sh b/eng/scripts/debug-staging.sh new file mode 100755 index 00000000000..483fa594fd5 --- /dev/null +++ b/eng/scripts/debug-staging.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Simulate an official PRERELEASE-shaped staging build (e.g. 13.4.0-preview.*) and +# validate that the CLI resolves Aspire.* from its SHA-specific darc feed. +# +# This is the scenario from https://github.com/microsoft/aspire/issues/17744: +# a prerelease-shaped staging build must use the darc-pub-microsoft-aspire- +# feed (quality=Both), NOT the shared daily feed. +# +# See docs/cli-staging-validation.md for the full validation matrix. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/debug-aspire-channel.sh" + +run_debug_channel staging "debug-staging.sh" "$@" From 06f2e92fb120e127b6b6431eebf9788ca5e84341 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 18:57:16 +1000 Subject: [PATCH 5/6] Isolate NuGet package cache in debug-channel --shell mode When dropping into the interactive subshell (--shell / -Shell), point NUGET_PACKAGES at an isolated, per-sha directory so packages restored from the simulated staging darc feed can never contaminate the developer's real global package cache. Also commit the staging-override NOTE clarifying the overrides route to but do not create a feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/cli-staging-validation.md | 4 +++- eng/scripts/debug-aspire-channel.ps1 | 10 ++++++++++ eng/scripts/debug-aspire-channel.sh | 10 ++++++++++ src/Aspire.Cli/Packaging/PackagingService.cs | 5 +++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/cli-staging-validation.md b/docs/cli-staging-validation.md index bd59635dc2c..e29034af883 100644 --- a/docs/cli-staging-validation.md +++ b/docs/cli-staging-validation.md @@ -97,7 +97,9 @@ three modes: - **`--print-env` / `-PrintEnv`:** emits `export`/`$env:` lines you apply to your current shell. Every subsequent `aspire` command then behaves like the simulated build. - **`--shell` / `-Shell`:** opens an interactive subshell with the overrides applied and - the target CLI first on `PATH`. Exiting the subshell restores normal behavior. + the target CLI first on `PATH`. It also points `NUGET_PACKAGES` at an isolated, per-sha + cache so restores from the simulated staging feed never contaminate your real global + package cache. Exiting the subshell restores normal behavior. Common flags: `--sha ` (required, 8–40 hex), `--cli ` (CLI to drive), `--pr ` (install that PR's full-bundle build first, then target it), `--version `. diff --git a/eng/scripts/debug-aspire-channel.ps1 b/eng/scripts/debug-aspire-channel.ps1 index 34ea8649ece..e521d31be87 100644 --- a/eng/scripts/debug-aspire-channel.ps1 +++ b/eng/scripts/debug-aspire-channel.ps1 @@ -138,13 +138,23 @@ function Invoke-DebugChannel { # is put first on PATH so a bare 'aspire' resolves to the target build. if ($Shell) { $cliDir = Split-Path -Parent $Cli + # Redirect NuGet's global packages folder to an isolated, per-sha directory + # so packages restored from the simulated staging feed (which can collide in + # version with packages already cached from real feeds) never contaminate the + # developer's real global cache (~/.nuget/packages by default). The directory + # is keyed by the simulated sha so repeat sessions reuse the same isolated + # cache, and is left in place on exit (it lives under the system temp dir). + $nugetPackages = Join-Path ([System.IO.Path]::GetTempPath()) (Join-Path 'aspire-debug-nuget' $sha8) + New-Item -ItemType Directory -Path $nugetPackages -Force | Out-Null Write-Host '>> Launching a child PowerShell. Run aspire new, aspire add, etc.' Write-Host " 'aspire' resolves to: $Cli" + Write-Host " NuGet packages cache: $nugetPackages (isolated from your global cache)" Write-Host " Type 'exit' to leave and restore normal CLI behavior." Write-Host '' $env:channel = 'staging' $env:overrideCliIdentityChannel = $Identity $env:overrideCliInformationalVersion = $infoVersion + $env:NUGET_PACKAGES = $nugetPackages $env:PATH = "$cliDir$([System.IO.Path]::PathSeparator)$env:PATH" & (Get-Process -Id $PID).Path -NoExit -NoLogo return diff --git a/eng/scripts/debug-aspire-channel.sh b/eng/scripts/debug-aspire-channel.sh index d3c97819342..46c799de764 100755 --- a/eng/scripts/debug-aspire-channel.sh +++ b/eng/scripts/debug-aspire-channel.sh @@ -210,13 +210,23 @@ ENV if [[ "$mode" == "shell" ]]; then local cli_dir cli_dir="$(dirname "$cli_path")" + # Redirect NuGet's global packages folder to an isolated, per-sha directory + # so packages restored from the simulated staging feed (which can collide in + # version with packages already cached from real feeds) never contaminate the + # developer's real global cache (~/.nuget/packages by default). The directory + # is keyed by the simulated sha so repeat sessions reuse the same isolated + # cache, and is left in place on exit (it lives under the system temp dir). + local nuget_packages="${TMPDIR:-/tmp}/aspire-debug-nuget/${sha8}" + mkdir -p "$nuget_packages" say ">> Launching an interactive subshell. Run 'aspire new', 'aspire add', etc." say " 'aspire' resolves to: $cli_path" + say " NuGet packages cache: $nuget_packages (isolated from your global cache)" say " Type 'exit' to leave and restore normal CLI behavior." say "" channel="staging" \ overrideCliIdentityChannel="$identity" \ overrideCliInformationalVersion="$info_version" \ + NUGET_PACKAGES="$nuget_packages" \ PATH="${cli_dir}:${PATH}" \ ASPIRE_DEBUG_BUILD_PROMPT="aspire(${kind}:${sha8})" \ "${SHELL:-/bin/bash}" -i diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 37b43863728..c242229d0c6 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -59,6 +59,11 @@ internal class PackagingService : IPackagingService // overrideCliInformationalVersion - forces the AssemblyInformationalVersion that the SHA // derivation and version-shape (quality) checks read, // e.g. `13.4.0-preview.1.26280.6+`. + // + // NOTE: These only route to a feed; they do not create one. They are typically useful only + // once the darc-pub-microsoft-aspire- 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"; From ca752eec5fe95f4cbd7f93ce4b3b763618a31eab Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 19:19:11 +1000 Subject: [PATCH 6/6] Fix staging-identity update test for identity-driven darc feed routing The staging identity now always routes to its build's SHA-specific darc-pub-microsoft-aspire- feed regardless of version shape, so the feed must be derivable from the CLI's + informational version. The test host assembly has no commit metadata, so the staging channel could not be synthesized and the test regressed. Stamp a staging-shaped informational version via the overrideCliInformationalVersion config so the derivation matches a real staging build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 1ca9bb6be7c..1757b200544 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -1923,6 +1923,17 @@ public async Task UpdateCommand_WhenStagingIdentityRegistersChannel_UsesStagingF { options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + // A real staging build always bakes the build's commit hash into its + // AssemblyInformationalVersion (e.g. "13.4.0-preview.1.26280.6+"), and the staging + // identity now routes to that build's SHA-specific darc-pub-microsoft-aspire- feed + // regardless of version shape. The test host assembly has no + metadata, so the + // feed could not be derived and the staging channel would never be synthesized. Provide a + // stamped informational version override so the derivation matches a real staging build. + options.ConfigurationCallback += config => + { + config[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+2574ef57e97fc393aff67592fd442afca6a6d02f"; + }; + options.ProjectLocatorFactory = _ => new TestProjectLocator() { UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>