|
@@ -1,313 +1,464 @@
|
|
|
+#nullable enable
|
|
|
+
|
|
|
using System;
|
|
|
using System.Collections.Generic;
|
|
|
-using System.Diagnostics;
|
|
|
+using System.Collections.Immutable;
|
|
|
using System.IO;
|
|
|
using System.IO.Compression;
|
|
|
using System.Linq;
|
|
|
-using System.Net;
|
|
|
-using System.Net.Http;
|
|
|
-using System.Text.RegularExpressions;
|
|
|
+using System.Security.Cryptography;
|
|
|
+using System.Threading;
|
|
|
using System.Threading.Tasks;
|
|
|
+using NuGet.Common;
|
|
|
+using NuGet.Configuration;
|
|
|
+using NuGet.Frameworks;
|
|
|
+using NuGet.Packaging;
|
|
|
+using NuGet.Protocol;
|
|
|
+using NuGet.Protocol.Core.Types;
|
|
|
+using NuGet.Versioning;
|
|
|
+using Nuke.Common.IO;
|
|
|
using Nuke.Common.Tooling;
|
|
|
-using Serilog;
|
|
|
using static Serilog.Log;
|
|
|
|
|
|
public static class ApiDiffHelper
|
|
|
{
|
|
|
- static readonly HttpClient s_httpClient = new();
|
|
|
-
|
|
|
- public static async Task GetDiff(
|
|
|
- Tool apiDiffTool, string outputFolder,
|
|
|
- string packagePath, string baselineVersion)
|
|
|
+ const string NightlyFeedUri = "https://nuget-feed-nightly.avaloniaui.net/v3/index.json";
|
|
|
+ const string MainPackageName = "Avalonia";
|
|
|
+ const string FolderLib = "lib";
|
|
|
+
|
|
|
+ public static void ValidatePackage(
|
|
|
+ Tool apiCompatTool,
|
|
|
+ PackageDiffInfo packageDiff,
|
|
|
+ AbsolutePath suppressionFilesFolderPath,
|
|
|
+ bool updateSuppressionFile)
|
|
|
{
|
|
|
- await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion);
|
|
|
- if (baselineStream == null)
|
|
|
- return;
|
|
|
+ Information("Validating API for package {Id}", packageDiff.PackageId);
|
|
|
|
|
|
- if (!Directory.Exists(outputFolder))
|
|
|
- {
|
|
|
- Directory.CreateDirectory(outputFolder!);
|
|
|
- }
|
|
|
+ Directory.CreateDirectory(suppressionFilesFolderPath);
|
|
|
|
|
|
- using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read))
|
|
|
- using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read))
|
|
|
- using (Helpers.UseTempDir(out var tempFolder))
|
|
|
- {
|
|
|
- var targetDlls = GetDlls(target);
|
|
|
- var baselineDlls = GetDlls(baseline);
|
|
|
+ var suppressionArgs = "";
|
|
|
+
|
|
|
+ var suppressionFile = suppressionFilesFolderPath / (packageDiff.PackageId + ".nupkg.xml");
|
|
|
+ if (suppressionFile.FileExists())
|
|
|
+ suppressionArgs += $""" --suppression-file="{suppressionFile}" --permit-unnecessary-suppressions """;
|
|
|
|
|
|
- var pairs = new List<(string baseline, string target)>();
|
|
|
+ if (updateSuppressionFile)
|
|
|
+ suppressionArgs += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file --preserve-unnecessary-suppressions """;
|
|
|
|
|
|
- var packageId = GetPackageId(packagePath);
|
|
|
+ var allErrors = new List<string>();
|
|
|
|
|
|
- // Don't use Path.Combine with these left and right tool parameters.
|
|
|
- // Microsoft.DotNet.ApiCompat.Tool is stupid and treats '/' and '\' as different assemblies in suppression files.
|
|
|
- // So, always use Unix '/'
|
|
|
- foreach (var baselineDll in baselineDlls)
|
|
|
+ Parallel.ForEach(
|
|
|
+ packageDiff.Frameworks,
|
|
|
+ framework =>
|
|
|
{
|
|
|
- var baselineDllPath = await ExtractDll("baseline", baselineDll, tempFolder);
|
|
|
+ var args = $""" -l="{framework.BaselineFolderPath}" -r="{framework.CurrentFolderPath}" {suppressionArgs}""";
|
|
|
|
|
|
- var targetTfm = baselineDll.target;
|
|
|
- var targetDll = targetDlls.FirstOrDefault(e =>
|
|
|
- e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
|
|
|
- if (targetDll is null)
|
|
|
- {
|
|
|
- if (s_tfmRedirects.FirstOrDefault(t => baselineDll.target.StartsWith(t.oldTfm) && (t.package is null || packageId == t.package)).newTfm is {} newTfm)
|
|
|
- {
|
|
|
- targetTfm = newTfm;
|
|
|
- targetDll = targetDlls.FirstOrDefault(e =>
|
|
|
- e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
|
|
|
- }
|
|
|
- }
|
|
|
+ var localErrors = GetErrors(apiCompatTool(args));
|
|
|
|
|
|
- if (targetDll?.entry is null)
|
|
|
+ if (localErrors.Length > 0)
|
|
|
{
|
|
|
- throw new InvalidOperationException($"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}");
|
|
|
+ lock (allErrors)
|
|
|
+ allErrors.AddRange(localErrors);
|
|
|
}
|
|
|
+ });
|
|
|
|
|
|
- var targetDllPath = await ExtractDll("target", targetDll, tempFolder);
|
|
|
+ ThrowOnErrors(allErrors, packageDiff.PackageId, "ValidateApiDiff");
|
|
|
+ }
|
|
|
|
|
|
- pairs.Add((baselineDllPath, targetDllPath));
|
|
|
- }
|
|
|
+ public static void GenerateMarkdownDiff(
|
|
|
+ Tool apiDiffTool,
|
|
|
+ PackageDiffInfo packageDiff,
|
|
|
+ AbsolutePath rootOutputFolderPath,
|
|
|
+ string baselineDisplay,
|
|
|
+ string currentDisplay)
|
|
|
+ {
|
|
|
+ Information("Creating markdown diff for package {Id}", packageDiff.PackageId);
|
|
|
|
|
|
- await Task.WhenAll(pairs.Select(p => Task.Run(() =>
|
|
|
- {
|
|
|
- var baselineApi = p.baseline + Random.Shared.Next() + ".api.cs";
|
|
|
- var targetApi = p.target + Random.Shared.Next() + ".api.cs";
|
|
|
- var resultDiff = p.target + ".api.diff.cs";
|
|
|
-
|
|
|
- GenerateApiListing(apiDiffTool, p.baseline, baselineApi, tempFolder);
|
|
|
- GenerateApiListing(apiDiffTool, p.target, targetApi, tempFolder);
|
|
|
-
|
|
|
- var args = $"""-c core.autocrlf=false diff --no-index --minimal """;
|
|
|
- args += """--ignore-matching-lines="^\[assembly: System.Reflection.AssemblyVersionAttribute" """;
|
|
|
- args += $""" --output {resultDiff} {baselineApi} {targetApi}""";
|
|
|
-
|
|
|
- using (var gitProcess = new Process())
|
|
|
+ var packageOutputFolderPath = rootOutputFolderPath / packageDiff.PackageId;
|
|
|
+ Directory.CreateDirectory(packageOutputFolderPath);
|
|
|
+
|
|
|
+ // Not specifying -eattrs incorrectly tries to load AttributesToExclude.txt, create an empty file instead.
|
|
|
+ // See https://github.com/dotnet/sdk/issues/49719
|
|
|
+ var excludedAttributesFilePath = (AbsolutePath)Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
|
+ File.WriteAllBytes(excludedAttributesFilePath!, []);
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var allErrors = new List<string>();
|
|
|
+
|
|
|
+ // The API diff tool is unbelievably slow, process in parallel.
|
|
|
+ Parallel.ForEach(
|
|
|
+ packageDiff.Frameworks,
|
|
|
+ framework =>
|
|
|
{
|
|
|
- gitProcess.StartInfo = new ProcessStartInfo
|
|
|
+ var frameworkOutputFolderPath = packageOutputFolderPath / framework.Framework.GetShortFolderName();
|
|
|
+ var args = $""" -b="{framework.BaselineFolderPath}" -bfn="{baselineDisplay}" -a="{framework.CurrentFolderPath}" -afn="{currentDisplay}" -o="{frameworkOutputFolderPath}" -eattrs="{excludedAttributesFilePath}" """;
|
|
|
+
|
|
|
+ var localErrors = GetErrors(apiDiffTool(args));
|
|
|
+
|
|
|
+ if (localErrors.Length > 0)
|
|
|
{
|
|
|
- CreateNoWindow = true,
|
|
|
- RedirectStandardError = false,
|
|
|
- RedirectStandardOutput = false,
|
|
|
- FileName = "git",
|
|
|
- Arguments = args,
|
|
|
- WorkingDirectory = tempFolder
|
|
|
- };
|
|
|
- gitProcess.Start();
|
|
|
- gitProcess.WaitForExit();
|
|
|
- }
|
|
|
+ lock (allErrors)
|
|
|
+ allErrors.AddRange(localErrors);
|
|
|
+ }
|
|
|
+ });
|
|
|
|
|
|
- var resultFile = new FileInfo(Path.Combine(tempFolder, resultDiff));
|
|
|
- if (resultFile.Length > 0)
|
|
|
- {
|
|
|
- resultFile.CopyTo(Path.Combine(outputFolder, Path.GetFileName(resultDiff)), true);
|
|
|
- }
|
|
|
- })));
|
|
|
+ ThrowOnErrors(allErrors, packageDiff.PackageId, "OutputApiDiff");
|
|
|
+
|
|
|
+ MergeFrameworkMarkdownDiffFiles(
|
|
|
+ rootOutputFolderPath,
|
|
|
+ packageOutputFolderPath,
|
|
|
+ [..packageDiff.Frameworks.Select(info => info.Framework)]);
|
|
|
+
|
|
|
+ Directory.Delete(packageOutputFolderPath, true);
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ File.Delete(excludedAttributesFilePath);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private static readonly (string package, string oldTfm, string newTfm)[] s_tfmRedirects = new[]
|
|
|
- {
|
|
|
- // We use StartsWith below comparing these tfm, as we ignore platform versions (like, net6.0-ios16.1).
|
|
|
- ("Avalonia.Android", "net6.0-android", "net8.0-android"),
|
|
|
- ("Avalonia.iOS", "net6.0-ios", "net8.0-ios"),
|
|
|
- // Browser was changed from net7.0 to net8.0-browser.
|
|
|
- ("Avalonia.Browser", "net7.0", "net8.0-browser"),
|
|
|
- ("Avalonia.Browser.Blazor", "net7.0", "net8.0-browser"),
|
|
|
- // Designer was moved from netcoreapp to netstandard.
|
|
|
- ("Avalonia", "netcoreapp2.0", "netstandard2.0"),
|
|
|
- ("Avalonia", "net461", "netstandard2.0")
|
|
|
- };
|
|
|
-
|
|
|
- public static async Task ValidatePackage(
|
|
|
- Tool apiCompatTool, string packagePath, string baselineVersion,
|
|
|
- string suppressionFilesFolder, bool updateSuppressionFile)
|
|
|
+ static void MergeFrameworkMarkdownDiffFiles(
|
|
|
+ AbsolutePath rootOutputFolderPath,
|
|
|
+ AbsolutePath packageOutputFolderPath,
|
|
|
+ ImmutableArray<NuGetFramework> frameworks)
|
|
|
{
|
|
|
- if (!Directory.Exists(suppressionFilesFolder))
|
|
|
+ // At this point, the hierarchy looks like:
|
|
|
+ // markdown/
|
|
|
+ // ├─ net8.0/
|
|
|
+ // │ ├─ api_diff_Avalonia.md
|
|
|
+ // │ ├─ api_diff_Avalonia.Controls.md
|
|
|
+ // ├─ netstandard2.0/
|
|
|
+ // │ ├─ api_diff_Avalonia.md
|
|
|
+ // │ ├─ api_diff_Avalonia.Controls.md
|
|
|
+ //
|
|
|
+ // We want one file per assembly: merge all files with the same name.
|
|
|
+ // However, it's very likely that the diff is the same for several frameworks: in this case, keep only one file.
|
|
|
+
|
|
|
+ var assemblyGroups = frameworks
|
|
|
+ .SelectMany(GetFrameworkDiffFiles, (framework, filePath) => (framework, filePath))
|
|
|
+ .GroupBy(x => x.filePath.Name)
|
|
|
+ .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ foreach (var assemblyGroup in assemblyGroups)
|
|
|
{
|
|
|
- Directory.CreateDirectory(suppressionFilesFolder!);
|
|
|
+ using var writer = File.CreateText(rootOutputFolderPath / assemblyGroup.Key.Replace("api_diff_", ""));
|
|
|
+ var addSeparator = false;
|
|
|
+
|
|
|
+ foreach (var similarDiffGroup in assemblyGroup.GroupBy(x => HashFile(x.filePath), ByteArrayEqualityComparer.Instance))
|
|
|
+ {
|
|
|
+ if (addSeparator)
|
|
|
+ writer.WriteLine();
|
|
|
+
|
|
|
+ using var reader = File.OpenText(similarDiffGroup.First().filePath);
|
|
|
+ var firstLine = reader.ReadLine();
|
|
|
+
|
|
|
+ writer.Write(firstLine);
|
|
|
+ writer.WriteLine(" (" + string.Join(", ", similarDiffGroup.Select(x => x.framework.GetShortFolderName())) + ")");
|
|
|
+
|
|
|
+ while (reader.ReadLine() is { } line)
|
|
|
+ writer.WriteLine(line);
|
|
|
+
|
|
|
+ addSeparator = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ AbsolutePath[] GetFrameworkDiffFiles(NuGetFramework framework)
|
|
|
+ {
|
|
|
+ var frameworkFolderPath = packageOutputFolderPath / framework.GetShortFolderName();
|
|
|
+ if (!frameworkFolderPath.DirectoryExists())
|
|
|
+ return [];
|
|
|
+
|
|
|
+ return Directory.GetFiles(frameworkFolderPath, "*.md")
|
|
|
+ .Where(filePath => Path.GetFileName(filePath) != "api_diff.md")
|
|
|
+ .Select(filePath => (AbsolutePath)filePath)
|
|
|
+ .ToArray();
|
|
|
}
|
|
|
|
|
|
- await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion);
|
|
|
- if (baselineStream == null)
|
|
|
+ static byte[] HashFile(AbsolutePath filePath)
|
|
|
+ {
|
|
|
+ using var stream = File.OpenRead(filePath);
|
|
|
+ return SHA256.HashData(stream);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void MergePackageMarkdownDiffFiles(
|
|
|
+ AbsolutePath rootOutputFolderPath,
|
|
|
+ string baselineDisplay,
|
|
|
+ string currentDisplay)
|
|
|
+ {
|
|
|
+ const string mergedFileName = "_diff.md";
|
|
|
+
|
|
|
+ var filePaths = Directory.EnumerateFiles(rootOutputFolderPath, "*.md")
|
|
|
+ .Where(filePath => Path.GetFileName(filePath) != mergedFileName)
|
|
|
+ .Order(StringComparer.OrdinalIgnoreCase)
|
|
|
+ .ToArray();
|
|
|
+
|
|
|
+ using var writer = File.CreateText(rootOutputFolderPath / mergedFileName);
|
|
|
+
|
|
|
+ writer.WriteLine($"# API diff between {baselineDisplay} and {currentDisplay}");
|
|
|
+
|
|
|
+ if (filePaths.Length == 0)
|
|
|
+ {
|
|
|
+ writer.WriteLine();
|
|
|
+ writer.WriteLine("No changes.");
|
|
|
return;
|
|
|
+ }
|
|
|
|
|
|
- using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read))
|
|
|
- using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read))
|
|
|
- using (Helpers.UseTempDir(out var tempFolder))
|
|
|
+ foreach (var filePath in filePaths)
|
|
|
{
|
|
|
- var targetDlls = GetDlls(target);
|
|
|
- var baselineDlls = GetDlls(baseline);
|
|
|
+ writer.WriteLine();
|
|
|
+
|
|
|
+ using var reader = File.OpenText(filePath);
|
|
|
+
|
|
|
+ while (reader.ReadLine() is { } line)
|
|
|
+ {
|
|
|
+ if (line.StartsWith('#'))
|
|
|
+ writer.Write('#');
|
|
|
+
|
|
|
+ writer.WriteLine(line);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- var left = new List<string>();
|
|
|
- var right = new List<string>();
|
|
|
+ static string[] GetErrors(IEnumerable<Output> outputs)
|
|
|
+ => outputs
|
|
|
+ .Where(output => output.Type == OutputType.Err)
|
|
|
+ .Select(output => output.Text)
|
|
|
+ .ToArray();
|
|
|
|
|
|
- var packageId = GetPackageId(packagePath);
|
|
|
- var suppressionFile = Path.Combine(suppressionFilesFolder, packageId + ".nupkg.xml");
|
|
|
+ static void ThrowOnErrors(List<string> errors, string packageId, string taskName)
|
|
|
+ {
|
|
|
+ if (errors.Count > 0)
|
|
|
+ {
|
|
|
+ throw new AggregateException(
|
|
|
+ $"{taskName} task has failed for \"{packageId}\" package",
|
|
|
+ errors.Select(error => new Exception(error)));
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Don't use Path.Combine with these left and right tool parameters.
|
|
|
- // Microsoft.DotNet.ApiCompat.Tool is stupid and treats '/' and '\' as different assemblies in suppression files.
|
|
|
- // So, always use Unix '/'
|
|
|
- foreach (var baselineDll in baselineDlls)
|
|
|
+ public static async Task<GlobalDiffInfo> DownloadAndExtractPackagesAsync(
|
|
|
+ IEnumerable<AbsolutePath> currentPackagePaths,
|
|
|
+ NuGetVersion currentVersion,
|
|
|
+ bool isReleaseBranch,
|
|
|
+ AbsolutePath outputFolderPath,
|
|
|
+ NuGetVersion? forcedBaselineVersion)
|
|
|
+ {
|
|
|
+ var downloadContext = await CreateNuGetDownloadContextAsync();
|
|
|
+ var baselineVersion = forcedBaselineVersion ??
|
|
|
+ await GetBaselineVersionAsync(downloadContext, currentVersion, isReleaseBranch);
|
|
|
+
|
|
|
+ Information("API baseline version is {Baseline} for current version {Current}", baselineVersion, currentVersion);
|
|
|
+
|
|
|
+ var memoryStream = new MemoryStream();
|
|
|
+ var packageDiffs = ImmutableArray.CreateBuilder<PackageDiffInfo>();
|
|
|
+
|
|
|
+ foreach (var packagePath in currentPackagePaths)
|
|
|
+ {
|
|
|
+ string packageId;
|
|
|
+ AbsolutePath currentFolderPath;
|
|
|
+ AbsolutePath baselineFolderPath;
|
|
|
+ Dictionary<NuGetFramework, string> currentFolderNames;
|
|
|
+ Dictionary<NuGetFramework, string> baselineFolderNames;
|
|
|
+
|
|
|
+ // Extract current package
|
|
|
+ using (var currentArchive = new ZipArchive(File.OpenRead(packagePath), ZipArchiveMode.Read, leaveOpen: false))
|
|
|
{
|
|
|
- var baselineDllPath = await ExtractDll("baseline", baselineDll, tempFolder);
|
|
|
+ using var packageReader = new PackageArchiveReader(currentArchive);
|
|
|
+ packageId = packageReader.NuspecReader.GetId();
|
|
|
+ currentFolderPath = outputFolderPath / "current" / packageId;
|
|
|
+ currentFolderNames = ExtractDiffableAssembliesFromPackage(currentArchive, currentFolderPath);
|
|
|
+ }
|
|
|
|
|
|
- var targetTfm = baselineDll.target;
|
|
|
- var targetDll = targetDlls.FirstOrDefault(e =>
|
|
|
- e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
|
|
|
- if (targetDll?.entry is null)
|
|
|
- {
|
|
|
- if (s_tfmRedirects.FirstOrDefault(t => baselineDll.target.StartsWith(t.oldTfm) && (t.package is null || packageId == t.package)).newTfm is {} newTfm)
|
|
|
- {
|
|
|
- targetTfm = newTfm;
|
|
|
- targetDll = targetDlls.FirstOrDefault(e =>
|
|
|
- e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
|
|
|
- }
|
|
|
- }
|
|
|
- if (targetDll?.entry is null && targetDlls.Count == 1)
|
|
|
- {
|
|
|
- targetDll = targetDlls.First();
|
|
|
- Warning(
|
|
|
- $"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}." +
|
|
|
- $"Resolved: {targetDll.target} ({targetDll.entry.Name})");
|
|
|
- }
|
|
|
+ // Download baseline package
|
|
|
+ memoryStream.Position = 0L;
|
|
|
+ memoryStream.SetLength(0L);
|
|
|
+ await DownloadBaselinePackageAsync(memoryStream, downloadContext, packageId, baselineVersion);
|
|
|
+ memoryStream.Position = 0L;
|
|
|
|
|
|
- if (targetDll?.entry is null)
|
|
|
- {
|
|
|
- if (packageId == "Avalonia"
|
|
|
- && baselineDll.target is "net461" or "netcoreapp2.0")
|
|
|
- {
|
|
|
- // In 11.1 we have removed net461 and netcoreapp2.0 targets from Avalonia package.
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- var actualTargets = string.Join(", ",
|
|
|
- targetDlls.Select(d => $"{d.target} ({d.entry.Name})"));
|
|
|
- throw new InvalidOperationException(
|
|
|
- $"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}."
|
|
|
- + $"\r\nActual targets: {actualTargets}.");
|
|
|
- }
|
|
|
+ // Extract baseline package
|
|
|
+ using (var baselineArchive = new ZipArchive(memoryStream, ZipArchiveMode.Read, leaveOpen: true))
|
|
|
+ {
|
|
|
+ baselineFolderPath = outputFolderPath / "baseline" / packageId;
|
|
|
+ baselineFolderNames = ExtractDiffableAssembliesFromPackage(baselineArchive, baselineFolderPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentFolderNames.Count == 0 && baselineFolderNames.Count == 0)
|
|
|
+ continue;
|
|
|
|
|
|
- var targetDllPath = await ExtractDll("target", targetDll, tempFolder);
|
|
|
+ var frameworkDiffs = new List<FrameworkDiffInfo>();
|
|
|
|
|
|
- left.Add(baselineDllPath);
|
|
|
- right.Add(targetDllPath);
|
|
|
+ // Handle frameworks that exist only in the current package.
|
|
|
+ foreach (var framework in currentFolderNames.Keys.Except(baselineFolderNames.Keys))
|
|
|
+ {
|
|
|
+ var folderName = currentFolderNames[framework];
|
|
|
+ Directory.CreateDirectory(baselineFolderPath / folderName);
|
|
|
+ baselineFolderNames.Add(framework, folderName);
|
|
|
}
|
|
|
|
|
|
- if (left.Any())
|
|
|
+ // Handle frameworks that exist only for the baseline package.
|
|
|
+ foreach (var framework in baselineFolderNames.Keys.Except(currentFolderNames.Keys))
|
|
|
{
|
|
|
- var args = $""" -l={string.Join(',', left)} -r="{string.Join(',', right)}" """;
|
|
|
- if (File.Exists(suppressionFile))
|
|
|
- {
|
|
|
- args += $""" --suppression-file="{suppressionFile}" """;
|
|
|
- }
|
|
|
+ var folderName = baselineFolderNames[framework];
|
|
|
+ Directory.CreateDirectory(currentFolderPath / folderName);
|
|
|
+ currentFolderNames.Add(framework, folderName);
|
|
|
+ }
|
|
|
|
|
|
- if (updateSuppressionFile)
|
|
|
- {
|
|
|
- args += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file=true """;
|
|
|
- }
|
|
|
+ foreach (var (framework, currentFolderName) in currentFolderNames)
|
|
|
+ {
|
|
|
+ var baselineFolderName = baselineFolderNames[framework];
|
|
|
|
|
|
- var result = apiCompatTool(args, tempFolder)
|
|
|
- .Where(t => t.Type == OutputType.Err).ToArray();
|
|
|
- if (result.Any())
|
|
|
- {
|
|
|
- throw new AggregateException(
|
|
|
- $"ApiDiffValidation task has failed for \"{Path.GetFileName(packagePath)}\" package",
|
|
|
- result.Select(r => new Exception(r.Text)));
|
|
|
- }
|
|
|
+ frameworkDiffs.Add(new FrameworkDiffInfo(
|
|
|
+ framework,
|
|
|
+ baselineFolderPath / FolderLib / baselineFolderName,
|
|
|
+ currentFolderPath / FolderLib / currentFolderName));
|
|
|
}
|
|
|
+
|
|
|
+ packageDiffs.Add(new PackageDiffInfo(packageId, [..frameworkDiffs]));
|
|
|
}
|
|
|
+
|
|
|
+ return new GlobalDiffInfo(baselineVersion, currentVersion, packageDiffs.DrainToImmutable());
|
|
|
}
|
|
|
|
|
|
- record DllEntry(string target, ZipArchiveEntry entry);
|
|
|
-
|
|
|
- static IReadOnlyCollection<DllEntry> GetDlls(ZipArchive archive)
|
|
|
+ static async Task<NuGetDownloadContext> CreateNuGetDownloadContextAsync()
|
|
|
{
|
|
|
- return archive.Entries
|
|
|
- .Where(e => Path.GetExtension(e.FullName) == ".dll"
|
|
|
- // Exclude analyzers and build task, as we don't care about breaking changes there
|
|
|
- && !e.FullName.Contains("analyzers/") && !e.FullName.Contains("analyzers\\")
|
|
|
- && !e.Name.Contains("Avalonia.Build.Tasks"))
|
|
|
- .Select(e => (
|
|
|
- entry: e,
|
|
|
- isRef: e.FullName.Contains("ref/") || e.FullName.Contains("ref\\"),
|
|
|
- target: Path.GetDirectoryName(e.FullName)!.Split(new [] { '/', '\\' }).Last())
|
|
|
- )
|
|
|
- .GroupBy(e => (e.target, e.entry.Name))
|
|
|
- .Select(g => g.MaxBy(e => e.isRef))
|
|
|
- .Select(e => new DllEntry(e.target, e.entry))
|
|
|
- .ToArray();
|
|
|
+ var packageSource = new PackageSource(NightlyFeedUri) { ProtocolVersion = 3 };
|
|
|
+ var repository = Repository.Factory.GetCoreV3(packageSource);
|
|
|
+ var findPackageByIdResource = await repository.GetResourceAsync<FindPackageByIdResource>();
|
|
|
+ return new NuGetDownloadContext(packageSource, findPackageByIdResource);
|
|
|
}
|
|
|
|
|
|
- static async Task<Stream> DownloadBaselinePackage(string packagePath, string baselineVersion)
|
|
|
+ /// <summary>
|
|
|
+ /// Finds the baseline version to diff against.
|
|
|
+ /// On release branches, use the latest stable version.
|
|
|
+ /// On the main branch and on PRs, use the latest nightly version.
|
|
|
+ /// This method assumes all packages share the same version.
|
|
|
+ /// </summary>
|
|
|
+ static async Task<NuGetVersion> GetBaselineVersionAsync(
|
|
|
+ NuGetDownloadContext context,
|
|
|
+ NuGetVersion currentVersion,
|
|
|
+ bool isReleaseBranch)
|
|
|
{
|
|
|
- if (baselineVersion is null)
|
|
|
+ var versions = await context.FindPackageByIdResource.GetAllVersionsAsync(
|
|
|
+ MainPackageName,
|
|
|
+ context.CacheContext,
|
|
|
+ NullLogger.Instance,
|
|
|
+ CancellationToken.None);
|
|
|
+
|
|
|
+ versions = versions.Where(v => v < currentVersion);
|
|
|
+
|
|
|
+ if (isReleaseBranch)
|
|
|
+ versions = versions.Where(v => !v.IsPrerelease);
|
|
|
+
|
|
|
+ return versions.OrderDescending().FirstOrDefault()
|
|
|
+ ?? throw new InvalidOperationException(
|
|
|
+ $"Could not find a version less than {currentVersion} for package {MainPackageName} in source {context.PackageSource.Source}");
|
|
|
+ }
|
|
|
+
|
|
|
+ static async Task DownloadBaselinePackageAsync(
|
|
|
+ Stream destinationStream,
|
|
|
+ NuGetDownloadContext context,
|
|
|
+ string packageId,
|
|
|
+ NuGetVersion version)
|
|
|
+ {
|
|
|
+ Information("Downloading {Id} {Version} baseline package", packageId, version);
|
|
|
+
|
|
|
+ var downloaded = await context.FindPackageByIdResource.CopyNupkgToStreamAsync(
|
|
|
+ packageId,
|
|
|
+ version,
|
|
|
+ destinationStream,
|
|
|
+ context.CacheContext,
|
|
|
+ NullLogger.Instance,
|
|
|
+ CancellationToken.None);
|
|
|
+
|
|
|
+ if (!downloaded)
|
|
|
{
|
|
|
throw new InvalidOperationException(
|
|
|
- "Build \"api-baseline\" parameter must be set when running Nuke CreatePackages");
|
|
|
+ $"Could not download version {version} for package {packageId} in source {context.PackageSource.Source}");
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- /*
|
|
|
- Gets package name from versions like:
|
|
|
- Avalonia.0.10.0-preview1
|
|
|
- Avalonia.11.0.999-cibuild0037534-beta
|
|
|
- Avalonia.11.0.0
|
|
|
- */
|
|
|
- var packageId = GetPackageId(packagePath);
|
|
|
- Information("Downloading {0} {1} baseline package", packageId, baselineVersion);
|
|
|
+ static Dictionary<NuGetFramework, string> ExtractDiffableAssembliesFromPackage(
|
|
|
+ ZipArchive packageArchive,
|
|
|
+ AbsolutePath destinationFolderPath)
|
|
|
+ {
|
|
|
+ var folderByFramework = new Dictionary<NuGetFramework, string>();
|
|
|
|
|
|
- try
|
|
|
- {
|
|
|
- using var response = await s_httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
|
|
|
- $"https://www.nuget.org/api/v2/package/{packageId}/{baselineVersion}"), HttpCompletionOption.ResponseHeadersRead);
|
|
|
- response.EnsureSuccessStatusCode();
|
|
|
-
|
|
|
- await using var stream = await response.Content.ReadAsStreamAsync();
|
|
|
- var memoryStream = new MemoryStream();
|
|
|
- await stream.CopyToAsync(memoryStream);
|
|
|
- memoryStream.Seek(0, SeekOrigin.Begin);
|
|
|
- return memoryStream;
|
|
|
- }
|
|
|
- catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
|
|
|
+ foreach (var entry in packageArchive.Entries)
|
|
|
{
|
|
|
- return null;
|
|
|
+ if (TryGetFrameworkFolderName(entry.FullName) is not { } folderName)
|
|
|
+ continue;
|
|
|
+
|
|
|
+ // Ignore platform versions: assume that e.g. net8.0-android34 and net8.0-android35 are the same for diff purposes.
|
|
|
+ var framework = WithoutPlatformVersion(NuGetFramework.ParseFolder(folderName));
|
|
|
+
|
|
|
+ if (folderByFramework.TryGetValue(framework, out var existingFolderName))
|
|
|
+ {
|
|
|
+ if (existingFolderName != folderName)
|
|
|
+ {
|
|
|
+ throw new InvalidOperationException(
|
|
|
+ $"Found two similar frameworks with different platform versions: {existingFolderName} and {folderName}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else
|
|
|
+ folderByFramework.Add(framework, folderName);
|
|
|
+
|
|
|
+ var targetFilePath = destinationFolderPath / entry.FullName;
|
|
|
+ Directory.CreateDirectory(targetFilePath.Parent);
|
|
|
+ entry.ExtractToFile(targetFilePath, overwrite: true);
|
|
|
}
|
|
|
- catch (Exception ex)
|
|
|
+
|
|
|
+ return folderByFramework;
|
|
|
+
|
|
|
+ static string? TryGetFrameworkFolderName(string entryPath)
|
|
|
{
|
|
|
- throw new InvalidOperationException($"Downloading baseline package for {packageId} {baselineVersion} failed.\r" + ex.Message, ex);
|
|
|
+ if (!entryPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
|
|
+ return null;
|
|
|
+
|
|
|
+ var segments = entryPath.Split('/');
|
|
|
+ if (segments is not [FolderLib, var name, ..])
|
|
|
+ return null;
|
|
|
+
|
|
|
+ return name;
|
|
|
}
|
|
|
+
|
|
|
+ // e.g. net8.0-android34.0 to net8.0-android
|
|
|
+ static NuGetFramework WithoutPlatformVersion(NuGetFramework value)
|
|
|
+ => value.HasPlatform && value.PlatformVersion != FrameworkConstants.EmptyVersion ?
|
|
|
+ new NuGetFramework(value.Framework, value.Version, value.Platform, FrameworkConstants.EmptyVersion) :
|
|
|
+ value;
|
|
|
}
|
|
|
|
|
|
- static async Task<string> ExtractDll(string basePath, DllEntry dllEntry, string targetFolder)
|
|
|
+ public sealed class GlobalDiffInfo(
|
|
|
+ NuGetVersion baselineVersion,
|
|
|
+ NuGetVersion currentVersion,
|
|
|
+ ImmutableArray<PackageDiffInfo> packages)
|
|
|
{
|
|
|
- var dllPath = $"{basePath}/{dllEntry.target}/{dllEntry.entry.Name}";
|
|
|
- var dllRealPath = Path.Combine(targetFolder, dllPath);
|
|
|
- Directory.CreateDirectory(Path.GetDirectoryName(dllRealPath)!);
|
|
|
- await using (var dllFile = File.Create(dllRealPath))
|
|
|
- {
|
|
|
- await dllEntry.entry.Open().CopyToAsync(dllFile);
|
|
|
- }
|
|
|
+ public NuGetVersion BaselineVersion { get; } = baselineVersion;
|
|
|
+ public NuGetVersion CurrentVersion { get; } = currentVersion;
|
|
|
+ public ImmutableArray<PackageDiffInfo> Packages { get; } = packages;
|
|
|
+ }
|
|
|
|
|
|
- return dllPath;
|
|
|
+ public sealed class PackageDiffInfo(string packageId, ImmutableArray<FrameworkDiffInfo> frameworks)
|
|
|
+ {
|
|
|
+ public string PackageId { get; } = packageId;
|
|
|
+ public ImmutableArray<FrameworkDiffInfo> Frameworks { get; } = frameworks;
|
|
|
}
|
|
|
|
|
|
- static void GenerateApiListing(Tool apiDiffTool, string inputFile, string outputFile, string workingDif)
|
|
|
+ public sealed class FrameworkDiffInfo(
|
|
|
+ NuGetFramework framework,
|
|
|
+ AbsolutePath baselineFolderPath,
|
|
|
+ AbsolutePath currentFolderPath)
|
|
|
{
|
|
|
- var args = $""" --assembly={inputFile} --output-path={outputFile} --include-assembly-attributes=true""";
|
|
|
- var result = apiDiffTool(args, workingDif)
|
|
|
- .Where(t => t.Type == OutputType.Err).ToArray();
|
|
|
- if (result.Any())
|
|
|
- {
|
|
|
- throw new AggregateException($"GetApi tool failed task has failed",
|
|
|
- result.Select(r => new Exception(r.Text)));
|
|
|
- }
|
|
|
+ public NuGetFramework Framework { get; } = framework;
|
|
|
+ public AbsolutePath BaselineFolderPath { get; } = baselineFolderPath;
|
|
|
+ public AbsolutePath CurrentFolderPath { get; } = currentFolderPath;
|
|
|
}
|
|
|
|
|
|
- static string GetPackageId(string packagePath)
|
|
|
+ sealed class NuGetDownloadContext(PackageSource packageSource, FindPackageByIdResource findPackageByIdResource)
|
|
|
{
|
|
|
- return Regex.Replace(
|
|
|
- Path.GetFileNameWithoutExtension(packagePath),
|
|
|
- """(\.\d+\.\d+\.\d+(?:-.+)?)$""", "");
|
|
|
+ public SourceCacheContext CacheContext { get; } = new();
|
|
|
+ public PackageSource PackageSource { get; } = packageSource;
|
|
|
+ public FindPackageByIdResource FindPackageByIdResource { get; } = findPackageByIdResource;
|
|
|
}
|
|
|
}
|