diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index d2f2ee36d5..b802589fc7 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -6,17 +6,18 @@ "build": { "type": "object", "properties": { - "Configuration": { - "type": "string", - "description": "configuration" + "api-baseline": { + "type": "string" + }, + "configuration": { + "type": "string" }, "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, - "ForceNugetVersion": { - "type": "string", - "description": "force-nuget-version" + "force-nuget-version": { + "type": "string" }, "Help": { "type": "boolean", @@ -89,17 +90,16 @@ "RunRenderTests", "RunTests", "RunToolsTests", + "ValidateApiDiff", "ZipFiles" ] } }, - "SkipPreviewer": { - "type": "boolean", - "description": "skip-previewer" + "skip-previewer": { + "type": "boolean" }, - "SkipTests": { - "type": "boolean", - "description": "skip-tests" + "skip-tests": { + "type": "boolean" }, "Target": { "type": "array", @@ -124,10 +124,14 @@ "RunRenderTests", "RunTests", "RunToolsTests", + "ValidateApiDiff", "ZipFiles" ] } }, + "update-api-suppression": { + "type": "boolean" + }, "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..a236178c83 --- /dev/null +++ b/nukebuild/ApiDiffValidation.cs @@ -0,0 +1,145 @@ +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 System.Threading.Tasks; +using Nuke.Common.Tooling; + +public static class ApiDiffValidation +{ + private static readonly HttpClient s_httpClient = new(); + + public static async Task ValidatePackage( + Tool apiCompatTool, string packagePath, string 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!); + } + + await using (var baselineStream = await 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)!); + await using (var baselineDllFile = File.Create(baselineDllRealPath)) + { + await baselineDll.entry.Open().CopyToAsync(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)!); + await using (var targetDllFile = File.Create(targetDllRealPath)) + { + await targetDll.entry.Open().CopyToAsync(targetDllFile); + } + + left.Add(baselineDllPath); + right.Add(targetDllPath); + } + + if (left.Any()) + { + var args = $""" -l={string.Join(',', left)} -r="{string.Join(',', right)}" """; + if (File.Exists(suppressionFile)) + { + args += $""" --suppression-file="{suppressionFile}" """; + } + + if (updateSuppressionFile) + { + args += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file=true """; + } + + 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))); + } + } + } + } + + private static IReadOnlyCollection<(string target, ZipArchiveEntry entry)> GetDlls(ZipArchive archive) + { + 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.Name.Contains("Avalonia.Build.Tasks")) + .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 async Task DownloadBaselinePackage(string packagePath, string baselineVersion) + { + Build.Information("Downloading {0} baseline package for version {1}", Path.GetFileName(packagePath), baselineVersion); + + try + { + /* + Gets package name from versions like: + Avalonia.0.10.0-preview1 + Avalonia.11.0.999-cibuild0037534-beta + Avalonia.11.0.0 + */ + var packageId = Regex.Replace( + Path.GetFileNameWithoutExtension(packagePath), + """(\.\d+\.\d+\.\d+(?:-.+)?)$""", ""); + + 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 (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..b82c446249 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", Framework = "net6.0")] + Tool ApiCompatTool; + protected override void OnBuildInitialized() { Parameters = new BuildParameters(this); @@ -278,7 +282,17 @@ partial class Build : NukeBuild RefAssemblyGenerator.GenerateRefAsmsInPackage(Parameters.NugetRoot / "Avalonia." + Parameters.Version + ".nupkg"); }); - + + Target ValidateApiDiff => _ => _ + .DependsOn(CreateNugetPackages) + .Executes(async () => + { + await Task.WhenAll( + Directory.GetFiles(Parameters.NugetRoot).Select(nugetPackage => ApiDiffValidation.ValidatePackage( + ApiCompatTool, nugetPackage, Parameters.ApiValidationBaseline, + Parameters.ApiValidationSuppressionFiles, Parameters.UpdateApiValidationSuppression))); + }); + Target RunTests => _ => _ .DependsOn(RunCoreLibsTests) .DependsOn(RunRenderTests) @@ -288,7 +302,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..897fdc84f1 100644 --- a/nukebuild/BuildParameters.cs +++ b/nukebuild/BuildParameters.cs @@ -10,18 +10,24 @@ using static Nuke.Common.IO.PathConstruction; public partial class Build { - [Parameter("configuration")] + [Parameter(Name = "configuration")] public string Configuration { get; set; } - [Parameter("skip-tests")] + [Parameter(Name = "skip-tests")] public bool SkipTests { get; set; } - [Parameter("force-nuget-version")] + [Parameter(Name = "force-nuget-version")] public string ForceNugetVersion { get; set; } - [Parameter("skip-previewer")] + [Parameter(Name = "skip-previewer")] public bool SkipPreviewer { get; set; } + [Parameter(Name = "api-baseline")] + public string ApiValidationBaseline { get; set; } + + [Parameter(Name = "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 string ApiValidationBaseline { get; } + public bool UpdateApiValidationSuppression { get; } + public AbsolutePath ApiValidationSuppressionFiles { get; } public BuildParameters(Build b) { @@ -103,6 +111,9 @@ public partial class Build // VERSION Version = b.ForceNugetVersion ?? GetVersion(); + ApiValidationBaseline = b.ApiValidationBaseline ?? new Version(new Version(Version).Major, 0).ToString(); + UpdateApiValidationSuppression = b.UpdateApiValidationSuppression ?? IsLocalBuild; + if (IsRunningOnAzure) { if (!IsNuGetRelease) @@ -125,6 +136,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 + +