diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index bf16ec26e56..8f7a8db0414 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -294,6 +294,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 391cf7adb02..c2da807d981 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -298,6 +298,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index e9bc936d6e9..8152e83ca9c 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,9 +16,20 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; + public SemVersion? VersionPrefix { get; } = versionPrefix; public string SourceDetails { get; } = ComputeSourceDetails(mappings); + private bool MatchesVersionPrefix(SemVersion semVer) + { + if (VersionPrefix is null) + { + return true; + } + + return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; + } + private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -69,7 +80,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -104,7 +115,7 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -159,7 +170,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages; + return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -170,14 +181,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 0456a85bf62..7bde39025b0 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; +using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -75,24 +76,34 @@ public Task> GetChannelsAsync(CancellationToken canc private PackageChannel? CreateStagingChannel() { - var stagingFeedUrl = GetStagingFeedUrl(); + var stagingQuality = GetStagingQuality(); + var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]); + + // 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; + + var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) { return null; } - var stagingQuality = GetStagingQuality(); + var versionPrefix = GetStagingVersionPrefix(); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: true, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily"); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); return stagingChannel; } - private string? GetStagingFeedUrl() + private string? GetStagingFeedUrl(bool useSharedFeed) { // Check for configuration override first var overrideFeed = configuration["overrideStagingFeed"]; @@ -107,6 +118,12 @@ public Task> GetChannelsAsync(CancellationToken canc // Invalid URL, fall through to default behavior } + // Use the shared daily feed when builds aren't marked stable + 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-dotnet-aspire-{commitHash}/nuget/v3/index.json var assembly = Assembly.GetExecutingAssembly(); @@ -148,4 +165,21 @@ private PackageChannelQuality GetStagingQuality() // Default to Stable if not specified or invalid return PackageChannelQuality.Stable; } + + private SemVersion? GetStagingVersionPrefix() + { + var versionPrefixValue = configuration["stagingVersionPrefix"]; + if (string.IsNullOrEmpty(versionPrefixValue)) + { + return null; + } + + // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison + if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) + { + return semVersion; + } + + return null; + } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 5928d77e2d5..74b71823ea7 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -482,4 +482,251 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab Assert.True(stableIndex < dailyIndex, "stable should come before daily"); Assert.True(dailyIndex < pr12345Index, "daily should come before pr-12345"); } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Prerelease but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Both but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Both" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride_UsesFeedOverride() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set both quality override AND feed override — feed override should win + var customFeed = "https://custom-feed.example.com/v3/index.json"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["overrideStagingFeed"] = customFeed + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + // When an explicit feed override is provided, globalPackagesFolder stays enabled + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal(customFeed, aspireMapping.Source); + } + + [Fact] + public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPackagesFolder() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Quality=Prerelease with no feed override → shared feed mode + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService( + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), + new FakeNuGetPackageCache(), + features, + configuration); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + + // Act + await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout(); + + // Assert + var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); + Assert.True(File.Exists(nugetConfigPath)); + + var configContent = await File.ReadAllTextAsync(nugetConfigPath); + Assert.DoesNotContain("globalPackagesFolder", configContent); + Assert.DoesNotContain(".nugetpackages", configContent); + + // Verify it still has the shared feed URL + Assert.Contains("dotnet9", configContent); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "13.2" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.NotNull(stagingChannel.VersionPrefix); + Assert.Equal(13, stagingChannel.VersionPrefix!.Major); + Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + { + // Arrange + 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"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "not-a-version" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } }