Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0a22ef9
Fix PR channel version selection in CLI
sebastienros Apr 13, 2026
39aff7d
Deduplicate PR version selection
sebastienros Apr 13, 2026
61a46d4
Address CLI review feedback
sebastienros Apr 13, 2026
f31eccc
Handle auto-selected add versions in k8s tests
sebastienros Apr 13, 2026
742357b
Fix CLI add package source forwarding
sebastienros Apr 13, 2026
b837e75
Fix CLI add PR hive restore
sebastienros Apr 13, 2026
c46d315
Fix PR hive add mapping and GA deploy wait
sebastienros Apr 14, 2026
15ac30a
Require explicit PR hive matching inputs
sebastienros Apr 14, 2026
36327f3
Fix PR hive package mappings
sebastienros Apr 14, 2026
596c1ca
Simplify PR hive package detection
sebastienros Apr 15, 2026
360f280
Restore aspire add E2E timeout
sebastienros Apr 15, 2026
ffd8112
Scope PR hive mappings to package dependencies
sebastienros Apr 16, 2026
dfe1b33
Merge remote-tracking branch 'origin/main' into sebastienros/sebros-f…
sebastienros Apr 16, 2026
0bd5fbb
Merge remote-tracking branch 'origin/main' into sebastienros/sebros-f…
sebastienros Apr 16, 2026
ab9af5c
Keep AppHost SDK in scoped PR hives
sebastienros Apr 16, 2026
80dc3a2
Revert "Keep AppHost SDK in scoped PR hives"
sebastienros Apr 16, 2026
faa7933
Keep AppHost SDK in scoped PR hives
sebastienros Apr 16, 2026
7a60155
Merge remote-tracking branch 'origin/main' into sebastienros/sebros-f…
sebastienros Apr 17, 2026
5445cda
Address JamesNK review feedback on PR #16125
mitchdenny Apr 20, 2026
ce9b06e
Merge main into PR branch to resolve conflicts
mitchdenny Apr 20, 2026
3730e7d
Remove unused HandleAspireAddVersionSelectionAsync method
mitchdenny Apr 20, 2026
d06cc06
Merge branch 'main' of https://github.com/microsoft/aspire into pr-16125
mitchdenny Apr 20, 2026
e5cf124
Trigger clean CI baseline build
mitchdenny Apr 20, 2026
d9b265a
Remove scoped NuGet config creation from aspire add
mitchdenny Apr 20, 2026
72f579a
Use unscoped PR channel for NuGet config in aspire add
mitchdenny Apr 20, 2026
ffd58fe
Merge branch 'main' of https://github.com/microsoft/aspire into pr-16125
mitchdenny Apr 21, 2026
d80bb9e
Fix PR hive NuGet config: add source without package source mapping
mitchdenny Apr 21, 2026
8572286
Ensure project directory exists before writing nuget.config
mitchdenny Apr 21, 2026
6d5e323
Retrigger CI
mitchdenny Apr 21, 2026
3b301d3
Dispose ServiceProvider before workspace to prevent Windows file lock
mitchdenny Apr 21, 2026
ff15330
Fix remaining ServiceProvider dispose issues in PR tests
mitchdenny Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal sealed class AddCommand : BaseCommand
private readonly IDotNetSdkInstaller _sdkInstaller;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly IAppHostProjectFactory _projectFactory;
private readonly FallbackProjectParser _fallbackProjectParser;

private static readonly Argument<string> s_integrationArgument = new("integration")
{
Expand All @@ -44,7 +45,7 @@ internal sealed class AddCommand : BaseCommand
Description = AddCommandStrings.SourceArgumentDescription
};

public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory)
public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, FallbackProjectParser fallbackProjectParser)
: base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_packagingService = packagingService;
Expand All @@ -53,6 +54,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera
_sdkInstaller = sdkInstaller;
_hostEnvironment = hostEnvironment;
_projectFactory = projectFactory;
_fallbackProjectParser = fallbackProjectParser;

Arguments.Add(s_integrationArgument);
Options.Add(s_appHostOption);
Expand Down Expand Up @@ -208,6 +210,19 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
};

// Add the package using the appropriate project handler
if (string.IsNullOrEmpty(source) && VersionHelper.IsPrChannel(selectedNuGetPackage.Channel.Name))
{
var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService);
var scopedPackageIds = GetScopedPrHivePackageIds(
effectiveAppHostProjectFile,
selectedNuGetPackage.Package.Id,
selectedNuGetPackage.Package.Version);
await nugetConfigPrompter.CreateOrUpdateWithoutPromptAsync(
effectiveAppHostProjectFile.Directory!,
selectedNuGetPackage.Channel.CreateScopedChannelForPackages(scopedPackageIds),
cancellationToken);
}

context = new AddPackageContext
{
AppHostFile = effectiveAppHostProjectFile,
Expand Down Expand Up @@ -294,7 +309,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
_ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound)
};

var packageVersions = possiblePackages.Where(p => p.Package.Id == selectedPackage.Package.Id);
var packageVersions = possiblePackages.Where(p => p.Package.Id == selectedPackage.Package.Id).ToArray();

// If any of the package versions are an exact match for the preferred version
// then we can skip the version prompt and just use that version.
Expand All @@ -304,6 +319,22 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
return preferredVersionPackage;
}

// When PR hives are present, prefer the package that exactly matches the installed
// CLI/SDK version so template- and add-generated projects stay on the same build.
var prChannelPackageVersions = packageVersions
.Where(p => VersionHelper.IsPrChannel(p.Channel.Name))
.ToArray();

if (VersionHelper.TryGetCurrentCliVersionMatch(
prChannelPackageVersions,
p => p.Package.Version,
out var cliVersionPackage,
channelName: null,
hasPrHives: ExecutionContext.GetPrHiveCount() > 0))
Comment thread
sebastienros marked this conversation as resolved.
{
return cliVersionPackage;
}

// In non-interactive mode, prefer the implicit/default channel first to keep
// package selection aligned with the project's configured feeds. Then select
// the latest version within the chosen channel.
Expand Down Expand Up @@ -339,6 +370,35 @@ internal static (string FriendlyName, NuGetPackage Package, PackageChannel Chann

return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel);
}

private IReadOnlyCollection<string> GetScopedPrHivePackageIds(FileInfo appHostProjectFile, string selectedPackageId, string selectedPackageVersion)
{
var packageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
selectedPackageId
};

if (!appHostProjectFile.Exists)
{
return packageIds;
}

using var projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile);
if (!projectDocument.RootElement.TryGetProperty("Properties", out var properties) ||
!properties.TryGetProperty("AspireHostingSDKVersion", out var sdkVersionElement))
{
return packageIds;
}

var sdkVersion = sdkVersionElement.GetString();
if (!string.Equals(sdkVersion, selectedPackageVersion, StringComparison.OrdinalIgnoreCase))
{
return packageIds;
}

packageIds.Add("Aspire.AppHost.Sdk");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_fallbackProjectParser.ParseProject(appHostProjectFile) throws ProjectUpdaterException if the project file is malformed or unreadable. This propagates through the NuGet config creation block (lines 216–223) and hits the generic catch (Exception ex) in ExecuteAsync, failing the entire aspire add command with a misleading error about package addition failure.

The method already handles the "file doesn't exist" case gracefully (line 381) but not the "file exists but can't be parsed" case. Since determining whether to include Aspire.AppHost.Sdk in scoped mappings is a best-effort enhancement, a parsing failure should not prevent the package from being added.

Consider wrapping the ParseProject call in a try-catch and returning packageIds (just the selected package) on failure, consistent with the existing "file doesn't exist" fallback:

JsonDocument? projectDocument;
try
{
    projectDocument = _fallbackProjectParser.ParseProject(appHostProjectFile);
}
catch (ProjectUpdaterException)
{
    return packageIds;
}

using (projectDocument)
{
    // existing property checks...
}

return packageIds;
}
}

internal interface IAddCommandPrompter
Expand Down
13 changes: 12 additions & 1 deletion src/Aspire.Cli/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
throw new InvalidOperationException("No template versions found");
}

var hasPrHives = _executionContext.GetPrHiveCount() > 0;
var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer);

// Check for explicit version specified via command line
Expand All @@ -796,7 +797,17 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
}
}

// If channel was specified via --channel option or global setting (but no --version),
if (VersionHelper.TryGetCurrentCliVersionMatch(
orderedPackagesFromChannels,
p => p.Package.Version,
out var cliVersionPackageFromChannel,
channelName: channelName,
hasPrHives: hasPrHives))
{
return cliVersionPackageFromChannel;
}

// If channel was specified via --channel option or global setting (but no --version),
// automatically select the highest version from that channel without prompting
if (hasChannelSetting)
{
Expand Down
16 changes: 14 additions & 2 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,21 @@ private async Task<ResolveTemplateVersionResult> ResolveCliTemplateVersionAsync(
return new ResolveTemplateVersionResult { ErrorMessage = errorMessage };
}

var packages = await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken);
var package = packages
var packages = (await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken))
.Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _))
.ToArray();
var hasPrHives = ExecutionContext.GetPrHiveCount() > 0;

NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch(
packages,
p => p.Version,
out var cliVersionPackage,
channelName: selectedChannel.Name,
hasPrHives: hasPrHives)
? cliVersionPackage
: null;

package ??= packages
.OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer)
.FirstOrDefault();

Expand Down
171 changes: 170 additions & 1 deletion src/Aspire.Cli/Packaging/PackageChannel.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO.Compression;
using System.Xml.Linq;
using Aspire.Cli.NuGet;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Semver;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;

Expand Down Expand Up @@ -193,6 +196,172 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
return filteredPackages;
}

public PackageChannel CreateScopedChannelForPackage(string packageId)
{
return CreateScopedChannelForPackages([packageId]);
}

public PackageChannel CreateScopedChannelForPackages(IEnumerable<string> packageIds)
{
ArgumentNullException.ThrowIfNull(packageIds);

var requestedPackageIds = packageIds
.Where(packageId => !string.IsNullOrWhiteSpace(packageId))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (requestedPackageIds.Length == 0)
{
throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds));
}

var mappings = Mappings;
if (!VersionHelper.IsPrChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 })
{
return this;
}

var scopedMappings = mappings
.SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds))
.ToArray();

return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion);
}

private static IEnumerable<PackageMapping> CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection<string> packageIds)
{
if (!IsScopedAspireMapping(mapping))
{
yield return mapping;
yield break;
}

var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds);

foreach (var scopedPackageId in scopedPackageIds)
{
yield return new PackageMapping(scopedPackageId, mapping.Source);
}
}

private static HashSet<string> GetScopedPackageIds(string source, IEnumerable<string> packageIds)
{
var resolvedPackageIds = new HashSet<string>(packageIds, StringComparer.OrdinalIgnoreCase);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a NuGetPackageId comparer and comparison to known comparers file. It would be OrdinalIgnoreCase still, but centralizing values would help having consistent comparison be used.

Update code to use central value.


if (!Directory.Exists(source))
{
return resolvedPackageIds;
}

var packageFiles = Directory.EnumerateFiles(source, "*.nupkg", SearchOption.TopDirectoryOnly)
.Select(GetPackageFileMetadata)
.OfType<PackageFileMetadata>()
.GroupBy(metadata => metadata.PackageId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(),
StringComparer.OrdinalIgnoreCase);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More places to use the comparer


var packagesToProcess = new Queue<string>(resolvedPackageIds);

while (packagesToProcess.Count > 0)
{
var currentPackageId = packagesToProcess.Dequeue();
if (!packageFiles.TryGetValue(currentPackageId, out var metadata))
{
continue;
}

foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath))
{
if (packageFiles.ContainsKey(dependencyPackageId) && resolvedPackageIds.Add(dependencyPackageId))
{
packagesToProcess.Enqueue(dependencyPackageId);
}
}
}

return resolvedPackageIds;
}

private static PackageFileMetadata? GetPackageFileMetadata(string packageFile)
{
var packageIdentity = TryGetPackageIdentityFromPackageFileName(packageFile);
if (packageIdentity is null)
{
return null;
}

return new PackageFileMetadata(packageIdentity.Value.PackageId, packageIdentity.Value.Version, packageFile);
}

private static IEnumerable<string> GetDependencyPackageIds(string packageFile)
{
try
{
using var archive = ZipFile.OpenRead(packageFile);
var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase));
if (nuspecEntry is null)
{
return [];
}

using var stream = nuspecEntry.Open();
var document = XDocument.Load(stream);
return document
.Descendants()
.Where(element => element.Name.LocalName == "dependency")
.Select(element => element.Attribute("id")?.Value)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Cast<string>()
.ToArray();
}
catch (IOException)
{
return [];
}
catch (InvalidDataException)
{
return [];
}
catch (System.Xml.XmlException)
{
return [];
Comment on lines +324 to +334
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there should be at least some debug level logging.

}
}

private static (string PackageId, SemVersion Version)? TryGetPackageIdentityFromPackageFileName(string packageFile)
{
var packageFileName = Path.GetFileNameWithoutExtension(packageFile);
if (string.IsNullOrWhiteSpace(packageFileName))
{
return null;
}

var separatorIndex = packageFileName.IndexOf('.');
while (separatorIndex >= 0 && separatorIndex < packageFileName.Length - 1)
{
var versionCandidate = packageFileName[(separatorIndex + 1)..];
if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out var version))
{
return (packageFileName[..separatorIndex], version);
}

separatorIndex = packageFileName.IndexOf('.', separatorIndex + 1);
}

return null;
}

private readonly record struct PackageFileMetadata(string PackageId, SemVersion Version, string PackageFilePath);

private static bool IsScopedAspireMapping(PackageMapping mapping)
{
return mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal);
}

public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null)
{
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion);
Expand All @@ -207,4 +376,4 @@ public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPacka
// for broader templating options.
return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache);
}
}
}
16 changes: 13 additions & 3 deletions src/Aspire.Cli/Templating/DotNetTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ private async Task<string> GetOutputPathAsync(TemplateInputs inputs, Func<string
}

IEnumerable<PackageChannel> channels;
var hasPrHives = executionContext.GetPrHiveCount() > 0;
bool hasChannelSetting = !string.IsNullOrEmpty(channelName);

if (hasChannelSetting)
Expand All @@ -671,8 +672,7 @@ private async Task<string> GetOutputPathAsync(TemplateInputs inputs, Func<string
{
// If there are hives (PR build directories), include all channels.
// Otherwise, only use the implicit/default channel to avoid prompting.
var hasHives = executionContext.GetPrHiveCount() > 0;
channels = hasHives
channels = hasPrHives
? allChannels
: allChannels.Where(c => c.Type is PackageChannelType.Implicit);
}
Expand Down Expand Up @@ -710,7 +710,17 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
}
}

// If channel was specified via --channel option or global setting (but no --version),
if (VersionHelper.TryGetCurrentCliVersionMatch(
orderedPackagesFromChannels,
p => p.Package.Version,
out var cliVersionPackageFromChannel,
channelName: channelName,
hasPrHives: hasPrHives))
{
return cliVersionPackageFromChannel;
}

// If channel was specified via --channel option or global setting (but no --version),
// automatically select the highest version from that channel without prompting
if (hasChannelSetting)
{
Expand Down
Loading
Loading