From 96f21b6cbf3cdc3d6eb96dc7a0e3eb22ea15c273 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 6 Jul 2023 14:17:01 -0700 Subject: [PATCH] Create initial ApiDiffValidation implementation --- .nuke/build.schema.json | 10 +++ nukebuild/ApiDiffValidation.cs | 123 +++++++++++++++++++++++++++++++++ nukebuild/Build.cs | 21 +++++- nukebuild/BuildParameters.cs | 15 +++- nukebuild/Shims.cs | 4 +- nukebuild/_build.csproj | 2 + 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 nukebuild/ApiDiffValidation.cs diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index d2f2ee36d5..8bc812b0e2 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -6,6 +6,10 @@ "build": { "type": "object", "properties": { + "ApiValidationBaseline": { + "type": "string", + "description": "api-baseline" + }, "Configuration": { "type": "string", "description": "configuration" @@ -89,6 +93,7 @@ "RunRenderTests", "RunTests", "RunToolsTests", + "ValidateApiDiff", "ZipFiles" ] } @@ -124,10 +129,15 @@ "RunRenderTests", "RunTests", "RunToolsTests", + "ValidateApiDiff", "ZipFiles" ] } }, + "UpdateApiValidationSuppression": { + "type": "boolean", + "description": "update-api-suppression" + }, "Verbosity": { "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", diff --git a/nukebuild/ApiDiffValidation.cs b/nukebuild/ApiDiffValidation.cs new file mode 100644 index 0000000000..5112f87728 --- /dev/null +++ b/nukebuild/ApiDiffValidation.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using Nuke.Common.Tooling; + +public static class ApiDiffValidation +{ + public static void ValidatePackage( + Tool apiCompatTool, string packagePath, Version baselineVersion, + string suppressionFilesFolder, bool updateSuppressionFile) + { + if (baselineVersion is null) + { + throw new InvalidOperationException( + "Build \"api-baseline\" parameter must be set when running Nuke CreatePackages"); + } + + if (!Directory.Exists(suppressionFilesFolder)) + { + Directory.CreateDirectory(suppressionFilesFolder!); + } + + using (var baselineStream = DownloadBaselinePackage(packagePath, baselineVersion)) + 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 left = new List(); + var right = new List(); + + var suppressionFile = Path.Combine(suppressionFilesFolder, Path.GetFileName(packagePath) + ".xml"); + + foreach (var baselineDll in baselineDlls) + { + var baselineDllPath = Path.Combine("baseline", baselineDll.target, baselineDll.entry.Name); + var baselineDllRealPath = Path.Combine(tempFolder, baselineDllPath); + Directory.CreateDirectory(Path.GetDirectoryName(baselineDllRealPath)!); + using (var baselineDllFile = File.Create(baselineDllRealPath)) + { + baselineDll.entry.Open().CopyTo(baselineDllFile); + } + + var targetDll = targetDlls.FirstOrDefault(e => + e.target == baselineDll.target && e.entry.Name == baselineDll.entry.Name); + if (targetDll.entry is null) + { + throw new InvalidOperationException($"Some assemblies are missing in the new package: {baselineDll.entry.Name} for {baselineDll.target}"); + } + + var targetDllPath = Path.Combine("target", targetDll.target, targetDll.entry.Name); + var targetDllRealPath = Path.Combine(tempFolder, targetDllPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetDllRealPath)!); + using (var targetDllFile = File.Create(targetDllRealPath)) + { + targetDll.entry.Open().CopyTo(targetDllFile); + } + + left.Add(baselineDllPath); + right.Add(targetDllPath); + } + + var args = $""" -l={string.Join(',', left)} -r="{string.Join(',', right)}" """; + updateSuppressionFile = true; + if (File.Exists(suppressionFile)) + { + args += $""" --suppression-file="{suppressionFile}" """; + } + if (updateSuppressionFile) + { + args += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file=true """; + } + + apiCompatTool(args, tempFolder); + } + } + + private static IReadOnlyCollection<(string target, ZipArchiveEntry entry)> GetDlls(ZipArchive archive) + { + return archive.Entries + .Where(e => Path.GetExtension(e.FullName) == ".dll") + .Select(e => ( + entry: e, + isRef: e.FullName.Contains("ref/"), + target: Path.GetDirectoryName(e.FullName)!.Split('/').Last()) + ) + .GroupBy(e => (e.target, e.entry.Name)) + .Select(g => g.MaxBy(e => e.isRef)) + .Select(e => (e.target, e.entry)) + .ToArray(); + } + + static Stream DownloadBaselinePackage(string packagePath, Version baselineVersion) + { + Build.Information("Downloading {0} baseline package for version {1}", Path.GetFileName(packagePath), baselineVersion); + + try + { + var packageId = Regex.Replace( + Path.GetFileNameWithoutExtension(packagePath), + """(\.\d+\.\d+\.\d+)$""", ""); + + using var httpClient = new HttpClient(); + using var response = httpClient.Send(new HttpRequestMessage(HttpMethod.Get, + $"https://www.nuget.org/api/v2/package/{packageId}/{baselineVersion}")); + using var stream = response.Content.ReadAsStream(); + var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + memoryStream.Seek(0, SeekOrigin.Begin); + return memoryStream; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Downloading baseline package for {packagePath} failed.\r" + ex.Message, ex); + } + } +} diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index 524c9fa4e4..644c1267d6 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -36,6 +36,10 @@ using MicroCom.CodeGenerator; partial class Build : NukeBuild { BuildParameters Parameters { get; set; } + + [PackageExecutable("Microsoft.DotNet.ApiCompat.Tool", "Microsoft.DotNet.ApiCompat.Tool.dll")] + Tool ApiCompatTool; + protected override void OnBuildInitialized() { Parameters = new BuildParameters(this); @@ -278,7 +282,19 @@ partial class Build : NukeBuild RefAssemblyGenerator.GenerateRefAsmsInPackage(Parameters.NugetRoot / "Avalonia." + Parameters.Version + ".nupkg"); }); - + + Target ValidateApiDiff => _ => _ + .DependsOn(CreateNugetPackages) + .Executes(() => + { + foreach (var nugetPackage in Directory.GetFiles(Parameters.NugetRoot)) + { + ApiDiffValidation.ValidatePackage( + ApiCompatTool, nugetPackage, Parameters.ApiValidationBaseline, + Parameters.ApiValidationSuppressionFiles, Parameters.UpdateApiValidationSuppression); + } + }); + Target RunTests => _ => _ .DependsOn(RunCoreLibsTests) .DependsOn(RunRenderTests) @@ -288,7 +304,8 @@ partial class Build : NukeBuild Target Package => _ => _ .DependsOn(RunTests) - .DependsOn(CreateNugetPackages); + .DependsOn(CreateNugetPackages) + .DependsOn(ValidateApiDiff); Target CiAzureLinux => _ => _ .DependsOn(RunTests); diff --git a/nukebuild/BuildParameters.cs b/nukebuild/BuildParameters.cs index dfa914d1db..67ed086e20 100644 --- a/nukebuild/BuildParameters.cs +++ b/nukebuild/BuildParameters.cs @@ -22,6 +22,12 @@ public partial class Build [Parameter("skip-previewer")] public bool SkipPreviewer { get; set; } + [Parameter("api-baseline")] + public string ApiValidationBaseline { get; set; } + + [Parameter("update-api-suppression")] + public bool UpdateApiValidationSuppression { get; set; } + public class BuildParameters { public string Configuration { get; } @@ -57,7 +63,9 @@ public partial class Build public string FileZipSuffix { get; } public AbsolutePath ZipCoreArtifacts { get; } public AbsolutePath ZipNuGetArtifacts { get; } - + public Version ApiValidationBaseline { get; } + public bool UpdateApiValidationSuppression { get; } + public AbsolutePath ApiValidationSuppressionFiles { get; } public BuildParameters(Build b) { @@ -65,6 +73,10 @@ public partial class Build Configuration = b.Configuration ?? "Release"; SkipTests = b.SkipTests; SkipPreviewer = b.SkipPreviewer; + ApiValidationBaseline = b.ApiValidationBaseline is not null ? + new Version(b.ApiValidationBaseline) : + new Version(11, 0); + UpdateApiValidationSuppression = b.UpdateApiValidationSuppression; // CONFIGURATION MainRepo = "https://github.com/AvaloniaUI/Avalonia"; @@ -125,6 +137,7 @@ public partial class Build FileZipSuffix = Version + ".zip"; ZipCoreArtifacts = ZipRoot / ("Avalonia-" + FileZipSuffix); ZipNuGetArtifacts = ZipRoot / ("Avalonia-NuGet-" + FileZipSuffix); + ApiValidationSuppressionFiles = RootDirectory / "api"; } string GetVersion() diff --git a/nukebuild/Shims.cs b/nukebuild/Shims.cs index 6f79972ad6..eecfcf6da1 100644 --- a/nukebuild/Shims.cs +++ b/nukebuild/Shims.cs @@ -9,12 +9,12 @@ using Numerge; public partial class Build { - static void Information(string info) + internal static void Information(string info) { Logger.Info(info); } - static void Information(string info, params object[] args) + internal static void Information(string info, params object[] args) { Logger.Info(info, args); } diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj index 30e1200220..43453833d7 100644 --- a/nukebuild/_build.csproj +++ b/nukebuild/_build.csproj @@ -22,6 +22,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + +