Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
113 changes: 44 additions & 69 deletions eng/update-dependencies/LcuVariableUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,16 @@
// See the LICENSE file in the project root for more information.

using System.Text.RegularExpressions;
using Microsoft.Playwright;
using Microsoft.DotNet.Framework.UpdateDependencies.UpdateCatalog;

namespace Microsoft.DotNet.Framework.UpdateDependencies;

/// <summary>
/// Updates Latest Cumulative Update (LCU) download URLs variables by fetching
/// the most recent update information from the Microsoft Update Catalog.
/// </summary>
internal sealed partial class LcuVariableUpdater : IVariableUpdater, IAsyncDisposable
internal sealed partial class LcuVariableUpdater(UpdateCatalogClient catalog) : IVariableUpdater
{
private static readonly BrowserNewContextOptions s_newBrowserOptions = new() { Locale = "en-US" };

private readonly Lazy<Task<IPlaywright>> _playwright;
private readonly Lazy<Task<IBrowser>> _browser;

public LcuVariableUpdater()
{
_playwright = new Lazy<Task<IPlaywright>>(Playwright.Playwright.CreateAsync);
_browser = new(
async () =>
{
var playwright = await _playwright.Value;
return await playwright.Chromium.LaunchAsync(
new BrowserTypeLaunchOptions { Headless = true }
);
}
);
}

/// <summary>
/// Matches LCU variable names in the format "lcu|{version}|{framework}"
/// where version contains digits and periods. Examples:
Expand Down Expand Up @@ -77,72 +58,66 @@ public async Task<string> GetNewValueAsync(string variableKey, IVariableContext
/// <returns>KB article download URL</returns>
private async Task<string> GetKbDownloadUrlAsync(string kb, string windowsVersion)
{
var browser = await _browser.Value;
var context = await browser.NewContextAsync(s_newBrowserOptions);
var page = await context.NewPageAsync();

await page.GotoAsync($"https://catalog.update.microsoft.com/Search.aspx?q={kb}");

// Some windows versions require a more precise regex to match the
// correct LCU in on the update catalog page. By convention, the
// Windows version is the second part of the version name.
var tableRowRegex = windowsVersion switch
Regex titleRegex = windowsVersion switch
{
"ltsc2022" => Server2022TableRowRegex,
"ltsc2025" => Server2025TableRowRegex,
_ => WindowsServerTableRowRegex
"ltsc2022" => Server2022TitleRegex,
"ltsc2025" => Server2025TitleRegex,
_ => WindowsServerTitleRegex
};

var downloadPopUpPage = await page.RunAndWaitForPopupAsync(
async () =>
{
await page
.GetByRole(AriaRole.Row, new PageGetByRoleOptions() { NameRegex = tableRowRegex, Exact = true })
.GetByRole(AriaRole.Button)
.ClickAsync();
}
);
// A KB has an update for each Windows version and architecture.
IReadOnlyList<CatalogUpdate> results = await catalog.SearchAsync(kb);
CatalogUpdate[] matchingUpdates = results
.Where(result => titleRegex.IsMatch(result.Title))
.ToArray();

var url = await downloadPopUpPage
.GetByRole(AriaRole.Link, s_getDownloadLinkOptions)
.GetAttributeAsync("href");
CatalogUpdate update = matchingUpdates switch
{
[var match] => match,

await context.CloseAsync();
[] => throw new InvalidOperationException(
$"No Update Catalog result for {kb} matches {windowsVersion}. "
+ $"Results: [{string.Join("; ", results.Select(result => result.Title))}]"),

Console.WriteLine($"{kb} download URL: {url}");
_ => throw new InvalidOperationException(
$"Multiple Update Catalog results for {kb} match {windowsVersion}: "
+ $"[{string.Join("; ", matchingUpdates.Select(match => match.Title))}]"),
};

return url ?? "";
}
IReadOnlyList<string> downloadUrls = await catalog.GetDownloadUrlsAsync(update);
string[] msuUrls = downloadUrls
.Where(url => MsuUrlRegex.IsMatch(url))
.ToArray();

public async ValueTask DisposeAsync()
{
if (_browser.IsValueCreated)
string msuUrl = msuUrls switch
{
var browser = await _browser.Value;
await browser.DisposeAsync();
}
[var url] => url,

if (_playwright.IsValueCreated)
{
var playwright = await _playwright.Value;
playwright.Dispose();
}
}
[] => throw new InvalidOperationException(
$"No MSU download URL for '{update.Title}'. URLs: [{string.Join("; ", downloadUrls)}]"),

private static readonly PageGetByRoleOptions s_getDownloadLinkOptions = new()
{
NameRegex = DownloadLinkRegex,
};
_ => throw new InvalidOperationException(
$"Multiple MSU download URLs for '{update.Title}': [{string.Join("; ", msuUrls)}]"),
};

Console.WriteLine($"{kb} download URL: {msuUrl}");

return msuUrl;
}

[GeneratedRegex(@"^windows.*\.msu$")]
private static partial Regex DownloadLinkRegex { get; }
// The file name at the end of the URL, e.g. ".../windows10.0-kb5126422-x64-ndp481_1e92b2.msu".
[GeneratedRegex(@"/windows[^/]*\.msu$")]
private static partial Regex MsuUrlRegex { get; }

[GeneratedRegex(@"server.*x64", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex WindowsServerTableRowRegex { get; }
private static partial Regex WindowsServerTitleRegex { get; }

[GeneratedRegex(@"server.*21H2.*x64", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex Server2022TableRowRegex { get; }
private static partial Regex Server2022TitleRegex { get; }

[GeneratedRegex(@"24H2.*x64", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex Server2025TableRowRegex { get; }
[GeneratedRegex(@"server.*24H2.*x64", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex Server2025TitleRegex { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.53.0" />
<PackageReference Include="NuGet.Versioning" Version="7.9.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
</ItemGroup>

Expand Down
114 changes: 114 additions & 0 deletions eng/update-dependencies/NuGet/NuGetFeedClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json;
using System.Text.Json.Serialization;
using NuGet.Versioning;

namespace Microsoft.DotNet.Framework.UpdateDependencies.NuGet;

/// <summary>
/// Client for a NuGet v3 feed. Reads the service index, then the package's
/// version list.
/// </summary>
internal sealed class NuGetFeedClient(HttpClient httpClient)
{
private const string PackageBaseAddress = "PackageBaseAddress/3.0.0";

private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true,
};

/// <summary>
/// Gets the highest stable version of a package. Prerelease versions are
/// ignored. The version list is not assumed to be sorted.
/// </summary>
public async Task<string> GetLatestStableVersionAsync(string serviceIndexUrl, string packageId)
{
string packageBaseAddress = await GetPackageBaseAddressAsync(serviceIndexUrl);
string versionsUrl =
$"{packageBaseAddress.TrimEnd('/')}/{packageId.ToLowerInvariant()}/index.json";

PackageVersions packageVersions = await GetJsonAsync<PackageVersions>(versionsUrl);

var stable = new List<NuGetVersion>();
foreach (string versionText in packageVersions.Versions)
{
if (!NuGetVersion.TryParse(versionText, out NuGetVersion? version))
{
throw new InvalidOperationException(
$"Package '{packageId}' has an unparseable version '{versionText}'.");
}

if (!version.IsPrerelease)
{
stable.Add(version);
}
}

if (stable.Count == 0)
{
throw new InvalidOperationException(
$"Package '{packageId}' has no stable version at {versionsUrl}.");
}

// OriginalVersion is the string the feed listed, not a normalized rewrite.
NuGetVersion latest = stable.Max()!;
return latest.OriginalVersion ?? latest.ToNormalizedString();
}

private async Task<string> GetPackageBaseAddressAsync(string serviceIndexUrl)
{
ServiceIndex index = await GetJsonAsync<ServiceIndex>(serviceIndexUrl);

string? packageBaseAddress = index.Resources
.FirstOrDefault(resource => IsPackageBaseAddress(resource.Type))
?.Id;

if (packageBaseAddress is null)
{
throw new InvalidOperationException(
$"NuGet service index {serviceIndexUrl} has no {PackageBaseAddress} resource.");
}

return packageBaseAddress;
}

private async Task<T> GetJsonAsync<T>(string url)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using HttpResponseMessage response = await httpClient.SendAsync(request);

if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new InvalidOperationException($"NuGet feed returned 404 for {url}.");
}

response.EnsureSuccessStatusCode();

string json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, s_jsonOptions) ??
throw new InvalidOperationException($"NuGet feed response from {url} was empty.");
}

private static bool IsPackageBaseAddress(JsonElement type)
{
return type.ValueKind switch
{
JsonValueKind.String => type.GetString() == PackageBaseAddress,
JsonValueKind.Array => type.EnumerateArray().Any(item => item.GetString() == PackageBaseAddress),
_ => false,
};
}

private sealed record ServiceIndex(IReadOnlyList<ServiceResource> Resources);

private sealed record ServiceResource(
[property: JsonPropertyName("@id")] string Id,
[property: JsonPropertyName("@type")] JsonElement Type);

private sealed record PackageVersions(IReadOnlyList<string> Versions);
}
72 changes: 72 additions & 0 deletions eng/update-dependencies/NuGetVariableUpdater.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Microsoft.DotNet.Framework.UpdateDependencies;

/// <summary>
/// Updates the NuGet CLI version variable to the latest recommended nuget.exe.
/// </summary>
internal sealed class NuGetVariableUpdater(HttpClient httpClient) : IVariableUpdater
{
private const string ToolsUrl = "https://dist.nuget.org/tools.json";

// Recommended for consumption. See https://learn.microsoft.com/nuget/api/tools-json
private const string ReleasedAndBlessed = "ReleasedAndBlessed";

private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true,
};

/// <inheritdoc/>
public bool ShouldUpdate(string variableKey, IVariableContext variables)
{
// The manifest has a single NuGet variable, "nuget|version".
return variableKey == "nuget|version";
}

/// <inheritdoc/>
public async Task<string> GetNewValueAsync(string variableKey, IVariableContext variables)
{
string version = await GetLatestVersionAsync();
Console.WriteLine($"NuGet version: {version}");
return version;
}

/// <summary>
/// Gets the latest recommended nuget.exe version from tools.json.
/// </summary>
/// <remarks>
/// tools.json is sorted by SemVer descending, so the latest recommended
/// version is the first entry with stage ReleasedAndBlessed.
/// </remarks>
private async Task<string> GetLatestVersionAsync()
{
using var request = new HttpRequestMessage(HttpMethod.Get, ToolsUrl);
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();

string json = await response.Content.ReadAsStringAsync();
NuGetTools tools = JsonSerializer.Deserialize<NuGetTools>(json, s_jsonOptions) ??
throw new InvalidOperationException($"tools.json response from {ToolsUrl} was empty.");

NuGetTool? latest = tools.NuGetExe.FirstOrDefault(tool => tool.Stage == ReleasedAndBlessed);
if (latest is null)
{
throw new InvalidOperationException(
$"tools.json from {ToolsUrl} has no ReleasedAndBlessed nuget.exe version.");
}

return latest.Version;
}

private sealed record NuGetTools(
[property: JsonPropertyName("nuget.exe")] IReadOnlyList<NuGetTool> NuGetExe);

private sealed record NuGetTool(string Version, string Stage);
}
Loading
Loading