Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f5243cc
Phase 0 - separate out the shareable walkthrough and hive determinati…
nagilson Jan 23, 2026
633e3e5
Phase 0 - Separate more walkthrough and installation itself into shar…
nagilson Jan 23, 2026
63d5add
Phase 0 - installation decision workflow shared
nagilson Jan 23, 2026
c8256a6
Phase 1 - add command
nagilson Jan 23, 2026
5861b70
Add tests - needs QA - see comment for more detail
nagilson Jan 23, 2026
930de36
Fix tests - but they need more QA now
nagilson Jan 23, 2026
5c46517
remove a ton of bloated tests from ai
nagilson Jan 23, 2026
d19dc79
Remove hardcoded SDK texts
nagilson Jan 23, 2026
a2455b7
Consolidate shared test code for e2e sdk vs runtime
nagilson Jan 23, 2026
c54dcd9
specific failure message for runtime trying to use feature band
nagilson Jan 23, 2026
35e31ee
Don't download runtime again if the sdk installed it already
nagilson Jan 24, 2026
f31de1d
Fix global.json parse + add test
nagilson Jan 24, 2026
b0822e2
dotnetup library installs runtimes
nagilson Jan 24, 2026
f7b1acf
share progress reporter to avoid extra \n
nagilson Jan 24, 2026
46bd718
PR Feedback from Myself
nagilson Jan 26, 2026
3a7c9ae
Block windowsdesktop runtime install on unix
nagilson Jan 26, 2026
f300d5a
Try to further simplify workflow code to be more readable
nagilson Jan 26, 2026
5766bd9
Fix test
nagilson Jan 26, 2026
ca43c19
Mock download operations for more thorough tests
nagilson Jan 26, 2026
80b904b
Fix comment
nagilson Jan 27, 2026
8515b67
Merge remote-tracking branch 'upstream/release/dnup' into nagilson-dn…
nagilson Feb 3, 2026
bff81a4
post merge fix
nagilson Feb 3, 2026
2743072
fix display calls
nagilson Feb 3, 2026
3662da7
Merge branch 'nagilson-install-runtimes' into nagilson-dnup-install-r…
nagilson Feb 4, 2026
08ef3be
Migrate to implementation of new design spec
nagilson Feb 4, 2026
5934697
Fix CI issue with temp directory
nagilson Feb 4, 2026
ecd1638
Merge branch 'release/dnup' into nagilson-dnup-install-runtimes-impl
nagilson Feb 4, 2026
fa8fab4
PR feedback
nagilson Feb 6, 2026
4d5d00a
move up admin install prompt
nagilson Feb 6, 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Microsoft.Dotnet.Installation;

public interface IDotnetReleaseInfoProvider
{
IEnumerable<string> GetSupportedChannels();
IEnumerable<string> GetSupportedChannels(bool includeFeatureBands = true);

ReleaseVersion? GetLatestVersion(InstallComponent component, string channel);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,23 @@ public ChannelVersionResolver(ReleaseManifest releaseManifest)
_releaseManifest = releaseManifest;
}

public IEnumerable<string> GetSupportedChannels()
public IEnumerable<string> GetSupportedChannels(bool includeFeatureBands = true)
{
var productIndex = _releaseManifest.GetReleasesIndex();
return ["latest", "preview", "lts", "sts",
..productIndex
.Where(p => p.IsSupported)
.OrderByDescending(p => p.LatestReleaseVersion)
.SelectMany(GetChannelsForProduct)
.SelectMany(p => GetChannelsForProduct(p, includeFeatureBands))
];

static IEnumerable<string> GetChannelsForProduct(Product product)
static IEnumerable<string> GetChannelsForProduct(Product product, bool includeFeatureBands)
{
if (!includeFeatureBands)
{
return [product.ProductVersion];
}

return [product.ProductVersion,
..product.GetReleasesAsync().GetAwaiter().GetResult()
.SelectMany(r => r.Sdks)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace Microsoft.Dotnet.Installation.Internal;
/// <summary>
/// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation.
/// </summary>
internal class DotnetArchiveDownloader : IDisposable
internal class DotnetArchiveDownloader : IArchiveDownloader
{
private const int MaxRetryCount = 3;
private const int RetryDelayMilliseconds = 1000;
Expand Down Expand Up @@ -223,10 +223,10 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest,
{
// Verify the cached file's hash
VerifyFileHash(cachedFilePath, expectedHash);

// Copy from cache to destination
File.Copy(cachedFilePath, destinationPath, overwrite: true);

// Report 100% progress immediately since we're using cache
progress?.Report(new DownloadProgress(100, 100));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,59 +12,86 @@

namespace Microsoft.Dotnet.Installation.Internal;

using Microsoft.Dotnet.Installation;

internal class DotnetArchiveExtractor : IDisposable
{
private readonly DotnetInstallRequest _request;
private readonly ReleaseVersion _resolvedVersion;
private readonly IProgressTarget _progressTarget;
private readonly IArchiveDownloader _archiveDownloader;
private readonly bool _shouldDisposeDownloader;
private string scratchDownloadDirectory;
private string? _archivePath;

public DotnetArchiveExtractor(DotnetInstallRequest request, ReleaseVersion resolvedVersion, ReleaseManifest releaseManifest, IProgressTarget progressTarget)
private IProgressReporter? _progressReporter;

public DotnetArchiveExtractor(
DotnetInstallRequest request,
ReleaseVersion resolvedVersion,
ReleaseManifest releaseManifest,
IProgressTarget progressTarget,
IArchiveDownloader? archiveDownloader = null)
{
_request = request;
_resolvedVersion = resolvedVersion;
_progressTarget = progressTarget;
scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName;

if (archiveDownloader != null)
{
_archiveDownloader = archiveDownloader;
_shouldDisposeDownloader = false;
}
else
{
_archiveDownloader = new DotnetArchiveDownloader(releaseManifest);
_shouldDisposeDownloader = true;
}
}

/// <summary>
/// Gets the scratch download directory path. Exposed for testing.
/// </summary>
internal string ScratchDownloadDirectory => scratchDownloadDirectory;

/// <summary>
/// Gets or creates the shared progress reporter for both Prepare and Commit phases.
/// This avoids multiple newlines from Spectre.Console Progress between phases.
/// </summary>
private IProgressReporter ProgressReporter => _progressReporter ??= _progressTarget.CreateProgressReporter();

public void Prepare()
{
using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Prepare");

using var archiveDownloader = new DotnetArchiveDownloader();
var archiveName = $"dotnet-{Guid.NewGuid()}";
_archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DotnetupUtilities.GetArchiveFileExtensionForPlatform());

using (var progressReporter = _progressTarget.CreateProgressReporter())
{
var downloadTask = progressReporter.AddTask($"Downloading .NET SDK {_resolvedVersion}", 100);
var reporter = new DownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}");

try
{
archiveDownloader.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter);
}
catch (Exception ex)
{
throw new Exception($"Failed to download .NET archive for version {_resolvedVersion}", ex);
}
string componentDescription = _request.Component.GetDisplayName();
var downloadTask = ProgressReporter.AddTask($"Downloading {componentDescription} {_resolvedVersion}", 100);
var reporter = new DownloadProgressReporter(downloadTask, $"Downloading {componentDescription} {_resolvedVersion}");

downloadTask.Value = 100;
try
{
_archiveDownloader.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter);
}
catch (Exception ex)
{
throw new Exception($"Failed to download .NET archive for version {_resolvedVersion}", ex);
}

downloadTask.Value = 100;
}
public void Commit()
{
using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Commit");

using (var progressReporter = _progressTarget.CreateProgressReporter())
{
var installTask = progressReporter.AddTask($"Installing .NET SDK {_resolvedVersion}", maxValue: 100);
string componentDescription = _request.Component.GetDisplayName();
var installTask = ProgressReporter.AddTask($"Installing {componentDescription} {_resolvedVersion}", maxValue: 100);

// Extract archive directly to target directory with special handling for muxer
ExtractArchiveDirectlyToTarget(_archivePath!, _request.InstallRoot.Path!, installTask);
installTask.Value = installTask.MaxValue;
}
// Extract archive directly to target directory with special handling for muxer
ExtractArchiveDirectlyToTarget(_archivePath!, _request.InstallRoot.Path!, installTask);
installTask.Value = installTask.MaxValue;
}

/// <summary>
Expand Down Expand Up @@ -336,6 +363,27 @@ private void ExtractZipEntry(ZipArchiveEntry entry, string targetDir, IProgressT

public void Dispose()
{
try
{
// Dispose the progress reporter to finalize progress display
_progressReporter?.Dispose();
}
catch
{
}

try
{
// Dispose the archive downloader if we created it
if (_shouldDisposeDownloader)
{
_archiveDownloader.Dispose();
}
}
catch
{
}

try
{
// Clean up temporary download directory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ namespace Microsoft.Dotnet.Installation.Internal;

internal class DotnetReleaseInfoProvider : IDotnetReleaseInfoProvider
{
public IEnumerable<string> GetSupportedChannels()
public IEnumerable<string> GetSupportedChannels(bool includeFeatureBands = true)
{
var releaseManifest = new ChannelVersionResolver();
return releaseManifest.GetSupportedChannels();
return releaseManifest.GetSupportedChannels(includeFeatureBands);
}
public ReleaseVersion? GetLatestVersion(InstallComponent component, string channel)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.Deployment.DotNet.Releases;

namespace Microsoft.Dotnet.Installation.Internal;

/// <summary>
/// Interface for downloading .NET archives. Enables testing without network access.
/// </summary>
internal interface IArchiveDownloader : IDisposable
{
/// <summary>
/// Downloads the archive for the specified installation request and verifies its hash.
/// </summary>
/// <param name="installRequest">The installation request containing component and install root info.</param>
/// <param name="resolvedVersion">The resolved version to download.</param>
/// <param name="destinationPath">The local path to save the downloaded file.</param>
/// <param name="progress">Optional progress reporting.</param>
void DownloadArchiveWithVerification(
DotnetInstallRequest installRequest,
ReleaseVersion resolvedVersion,
string destinationPath,
IProgress<DownloadProgress>? progress = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,41 @@ public bool IsFullySpecifiedVersion()
return ReleaseVersion.TryParse(Name, out _);
}

/// <summary>
/// Checks if the channel string looks like an SDK version or feature band pattern rather than a runtime version.
/// SDK versions have a third component >= 100 (e.g., "9.0.103", "9.0.304") or use "xx" patterns (e.g., "9.0.1xx").
/// Runtime versions have a third component < 100 (e.g., "9.0.12", "9.0.0").
/// </summary>
/// <remarks>
/// We cannot use ReleaseVersion.SdkFeatureBand here because ReleaseVersion parses any valid semantic version
/// without knowing if it's an SDK or runtime version. For example, both "9.0.103" (SDK) and "9.0.12" (runtime)
/// would parse successfully, but SdkFeatureBand would return 100 for the SDK version and 0 for the runtime version.
/// Since we're validating user input where we don't know the intent, we use a heuristic: any third component >= 100
/// or containing 'x' is likely an SDK version/feature band and should be rejected for runtime installations.
/// </remarks>
public bool IsSdkVersionOrFeatureBand()
{
var parts = Name.Split('.');
if (parts.Length < 3)
{
return false;
}

string thirdPart = parts[2];

// Check for feature band patterns like "1xx", "2xx", "12x"
if (thirdPart.Contains('x', StringComparison.OrdinalIgnoreCase))
{
return true;
}

// Check if it's a numeric SDK version (patch >= 100 indicates SDK, e.g., "9.0.103")
// Runtime patches are typically < 100 (e.g., "9.0.12")
if (int.TryParse(thirdPart, out int patch) && patch >= 100)
{
return true;
}

return false;
}
}
40 changes: 36 additions & 4 deletions src/Installer/dotnetup/ArchiveInstallationValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ public bool Validate(DotnetInstall install)
string dotnetMuxerPath = Path.Combine(installRoot, DotnetupUtilities.GetDotnetExeName());
if (!File.Exists(dotnetMuxerPath))
{
// Windows Desktop archive doesn't include the muxer or core runtime.
// If the component layout is correct, we can still consider the install valid.
if (install.Component == InstallComponent.WindowsDesktop)
{
string resolvedVersionLayout = install.Version.ToString();
if (ValidateComponentLayout(installRoot, resolvedVersionLayout, install.Component))
{
return true;
}
}
return false;
}

Expand All @@ -59,18 +69,38 @@ private static bool ValidateComponentLayout(string installRoot, string resolvedV
if (component == InstallComponent.SDK)
{
string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion);
return Directory.Exists(sdkDirectory);
return DirectoryExistsAndNotEmpty(sdkDirectory);
}

if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker))
{
string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion);
return Directory.Exists(runtimeDirectory);
return DirectoryExistsAndNotEmpty(runtimeDirectory);
}

return false;
}

private static bool DirectoryExistsAndNotEmpty(string path)
{
return Directory.Exists(path) && Directory.EnumerateFileSystemEntries(path).Any();
}

/// <summary>
/// Checks if the component files already exist on disk (e.g., from an SDK install that includes the runtime).
/// This is a lightweight check that doesn't validate the full installation integrity.
/// </summary>
public static bool ComponentFilesExist(DotnetInstall install)
{
string? installRoot = install.InstallRoot.Path;
if (string.IsNullOrEmpty(installRoot))
{
return false;
}

return ValidateComponentLayout(installRoot, install.Version.ToString(), install.Component);
}

private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component)
{
try
Expand All @@ -90,11 +120,13 @@ private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVers
return false;
}

string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString());
// The HostFxr returns paths like shared/Microsoft.NETCore.App (without version)
// but when comparing, we need to account for this
string expectedRuntimeBasePath = Path.Combine(installRoot, "shared", runtimeMoniker);
return environmentInfo.RuntimeInfo.Any(runtime =>
string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) &&
string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) &&
DotnetupUtilities.PathsEqual(runtime.Path, expectedRuntimePath));
DotnetupUtilities.PathsEqual(runtime.Path, expectedRuntimeBasePath));
}
catch
{
Expand Down
Loading