Browse Source

Create initial ApiDiffValidation implementation

Max Katz 2 years ago
parent
commit
96f21b6cbf

+ 10 - 0
.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'",

+ 123 - 0
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<string>();
+            var right = new List<string>();
+
+            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);
+        }
+    }
+}

+ 19 - 2
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);

+ 14 - 1
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()

+ 2 - 2
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);
     }

+ 2 - 0
nukebuild/_build.csproj

@@ -22,6 +22,8 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
+
+    <PackageDownload Include="Microsoft.DotNet.ApiCompat.Tool" Version="[7.0.305]" />
   </ItemGroup>
 
   <ItemGroup>