ApiDiffValidation.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.IO.Compression;
  5. using System.Linq;
  6. using System.Net;
  7. using System.Net.Http;
  8. using System.Text.RegularExpressions;
  9. using System.Threading.Tasks;
  10. using Nuke.Common.Tooling;
  11. public static class ApiDiffValidation
  12. {
  13. private static readonly HttpClient s_httpClient = new();
  14. private static readonly Dictionary<(string target, string asmName), (string target, string asmName)> s_assemblyRedirects = new()
  15. {
  16. [("net6.0-android31.0", "Avalonia.Android.dll")] = ("net7.0-android33.0", "Avalonia.Android.dll"),
  17. [("net6.0-ios16.1", "Avalonia.iOS.dll")] = ("net7.0-ios16.1", "Avalonia.iOS.dll")
  18. };
  19. public static async Task ValidatePackage(
  20. Tool apiCompatTool, string packagePath, string baselineVersion,
  21. string suppressionFilesFolder, bool updateSuppressionFile)
  22. {
  23. if (baselineVersion is null)
  24. {
  25. throw new InvalidOperationException(
  26. "Build \"api-baseline\" parameter must be set when running Nuke CreatePackages");
  27. }
  28. if (!Directory.Exists(suppressionFilesFolder))
  29. {
  30. Directory.CreateDirectory(suppressionFilesFolder!);
  31. }
  32. await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion);
  33. if (baselineStream == null)
  34. return;
  35. using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read))
  36. using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read))
  37. using (Helpers.UseTempDir(out var tempFolder))
  38. {
  39. var targetDlls = GetDlls(target);
  40. var baselineDlls = GetDlls(baseline);
  41. var left = new List<string>();
  42. var right = new List<string>();
  43. var packageId = GetPackageId(packagePath);
  44. var suppressionFile = Path.Combine(suppressionFilesFolder, packageId + ".nupkg.xml");
  45. // Don't use Path.Combine with these left and right tool parameters.
  46. // Microsoft.DotNet.ApiCompat.Tool is stupid and treats '/' and '\' as different assemblies in suppression files.
  47. // So, always use Unix '/'
  48. foreach (var baselineDll in baselineDlls)
  49. {
  50. var baselineDllPath = $"baseline/{baselineDll.target}/{baselineDll.entry.Name}";
  51. var baselineDllRealPath = Path.Combine(tempFolder, baselineDllPath);
  52. Directory.CreateDirectory(Path.GetDirectoryName(baselineDllRealPath)!);
  53. await using (var baselineDllFile = File.Create(baselineDllRealPath))
  54. {
  55. await baselineDll.entry.Open().CopyToAsync(baselineDllFile);
  56. }
  57. if (!s_assemblyRedirects.TryGetValue((baselineDll.target, baselineDll.entry.Name), out var lookupPair))
  58. {
  59. lookupPair = (baselineDll.target, baselineDll.entry.Name);
  60. }
  61. var targetDll = targetDlls.FirstOrDefault(e =>
  62. e.target == lookupPair.target && e.entry.Name == lookupPair.asmName);
  63. if (targetDll.entry is null)
  64. {
  65. throw new InvalidOperationException($"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}");
  66. }
  67. var targetDllPath = $"target/{targetDll.target}/{targetDll.entry.Name}";
  68. var targetDllRealPath = Path.Combine(tempFolder, targetDllPath);
  69. Directory.CreateDirectory(Path.GetDirectoryName(targetDllRealPath)!);
  70. await using (var targetDllFile = File.Create(targetDllRealPath))
  71. {
  72. await targetDll.entry.Open().CopyToAsync(targetDllFile);
  73. }
  74. left.Add(baselineDllPath);
  75. right.Add(targetDllPath);
  76. }
  77. if (left.Any())
  78. {
  79. var args = $""" -l={string.Join(',', left)} -r="{string.Join(',', right)}" """;
  80. if (File.Exists(suppressionFile))
  81. {
  82. args += $""" --suppression-file="{suppressionFile}" """;
  83. }
  84. if (updateSuppressionFile)
  85. {
  86. args += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file=true """;
  87. }
  88. var result = apiCompatTool(args, tempFolder)
  89. .Where(t => t.Type == OutputType.Err).ToArray();
  90. if (result.Any())
  91. {
  92. throw new AggregateException(
  93. $"ApiDiffValidation task has failed for \"{Path.GetFileName(packagePath)}\" package",
  94. result.Select(r => new Exception(r.Text)));
  95. }
  96. }
  97. }
  98. }
  99. private static IReadOnlyCollection<(string target, ZipArchiveEntry entry)> GetDlls(ZipArchive archive)
  100. {
  101. return archive.Entries
  102. .Where(e => Path.GetExtension(e.FullName) == ".dll"
  103. // Exclude analyzers and build task, as we don't care about breaking changes there
  104. && !e.FullName.Contains("analyzers/") && !e.FullName.Contains("analyzers\\")
  105. && !e.Name.Contains("Avalonia.Build.Tasks"))
  106. .Select(e => (
  107. entry: e,
  108. isRef: e.FullName.Contains("ref/") || e.FullName.Contains("ref\\"),
  109. target: Path.GetDirectoryName(e.FullName)!.Split(new [] { '/', '\\' }).Last())
  110. )
  111. .GroupBy(e => (e.target, e.entry.Name))
  112. .Select(g => g.MaxBy(e => e.isRef))
  113. .Select(e => (e.target, e.entry))
  114. .ToArray();
  115. }
  116. static async Task<Stream> DownloadBaselinePackage(string packagePath, string baselineVersion)
  117. {
  118. /*
  119. Gets package name from versions like:
  120. Avalonia.0.10.0-preview1
  121. Avalonia.11.0.999-cibuild0037534-beta
  122. Avalonia.11.0.0
  123. */
  124. var packageId = GetPackageId(packagePath);
  125. Build.Information("Downloading {0} {1} baseline package", packageId, baselineVersion);
  126. try
  127. {
  128. using var response = await s_httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
  129. $"https://www.nuget.org/api/v2/package/{packageId}/{baselineVersion}"), HttpCompletionOption.ResponseHeadersRead);
  130. response.EnsureSuccessStatusCode();
  131. await using var stream = await response.Content.ReadAsStreamAsync();
  132. var memoryStream = new MemoryStream();
  133. await stream.CopyToAsync(memoryStream);
  134. memoryStream.Seek(0, SeekOrigin.Begin);
  135. return memoryStream;
  136. }
  137. catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
  138. {
  139. return null;
  140. }
  141. catch (Exception ex)
  142. {
  143. throw new InvalidOperationException($"Downloading baseline package for {packageId} {baselineVersion} failed.\r" + ex.Message, ex);
  144. }
  145. }
  146. static string GetPackageId(string packagePath)
  147. {
  148. return Regex.Replace(
  149. Path.GetFileNameWithoutExtension(packagePath),
  150. """(\.\d+\.\d+\.\d+(?:-.+)?)$""", "");
  151. }
  152. }