ApiDiffHelper.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Diagnostics;
  4. using System.IO;
  5. using System.IO.Compression;
  6. using System.Linq;
  7. using System.Net;
  8. using System.Net.Http;
  9. using System.Text.RegularExpressions;
  10. using System.Threading.Tasks;
  11. using Nuke.Common.Tooling;
  12. using Serilog;
  13. using static Serilog.Log;
  14. public static class ApiDiffHelper
  15. {
  16. static readonly HttpClient s_httpClient = new();
  17. public static async Task GetDiff(
  18. Tool apiDiffTool, string outputFolder,
  19. string packagePath, string baselineVersion)
  20. {
  21. await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion);
  22. if (baselineStream == null)
  23. return;
  24. if (!Directory.Exists(outputFolder))
  25. {
  26. Directory.CreateDirectory(outputFolder!);
  27. }
  28. using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read))
  29. using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read))
  30. using (Helpers.UseTempDir(out var tempFolder))
  31. {
  32. var targetDlls = GetDlls(target);
  33. var baselineDlls = GetDlls(baseline);
  34. var pairs = new List<(string baseline, string target)>();
  35. var packageId = GetPackageId(packagePath);
  36. // Don't use Path.Combine with these left and right tool parameters.
  37. // Microsoft.DotNet.ApiCompat.Tool is stupid and treats '/' and '\' as different assemblies in suppression files.
  38. // So, always use Unix '/'
  39. foreach (var baselineDll in baselineDlls)
  40. {
  41. var baselineDllPath = await ExtractDll("baseline", baselineDll, tempFolder);
  42. var targetTfm = baselineDll.target;
  43. var targetDll = targetDlls.FirstOrDefault(e =>
  44. e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
  45. if (targetDll is null)
  46. {
  47. if (s_tfmRedirects.FirstOrDefault(t => baselineDll.target.StartsWith(t.oldTfm)).newTfm is {} newTfm)
  48. {
  49. targetTfm = newTfm;
  50. targetDll = targetDlls.FirstOrDefault(e =>
  51. e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
  52. }
  53. }
  54. if (targetDll?.entry is null)
  55. {
  56. throw new InvalidOperationException($"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}");
  57. }
  58. var targetDllPath = await ExtractDll("target", targetDll, tempFolder);
  59. pairs.Add((baselineDllPath, targetDllPath));
  60. }
  61. await Task.WhenAll(pairs.Select(p => Task.Run(() =>
  62. {
  63. var baselineApi = p.baseline + Random.Shared.Next() + ".api.cs";
  64. var targetApi = p.target + Random.Shared.Next() + ".api.cs";
  65. var resultDiff = p.target + ".api.diff.cs";
  66. GenerateApiListing(apiDiffTool, p.baseline, baselineApi, tempFolder);
  67. GenerateApiListing(apiDiffTool, p.target, targetApi, tempFolder);
  68. var args = $"""-c core.autocrlf=false diff --no-index --minimal """;
  69. args += """--ignore-matching-lines="^\[assembly: System.Reflection.AssemblyVersionAttribute" """;
  70. args += $""" --output {resultDiff} {baselineApi} {targetApi}""";
  71. using (var gitProcess = new Process())
  72. {
  73. gitProcess.StartInfo = new ProcessStartInfo
  74. {
  75. CreateNoWindow = true,
  76. RedirectStandardError = false,
  77. RedirectStandardOutput = false,
  78. FileName = "git",
  79. Arguments = args,
  80. WorkingDirectory = tempFolder
  81. };
  82. gitProcess.Start();
  83. gitProcess.WaitForExit();
  84. }
  85. var resultFile = new FileInfo(Path.Combine(tempFolder, resultDiff));
  86. if (resultFile.Length > 0)
  87. {
  88. resultFile.CopyTo(Path.Combine(outputFolder, Path.GetFileName(resultDiff)), true);
  89. }
  90. })));
  91. }
  92. }
  93. private static readonly (string oldTfm, string newTfm)[] s_tfmRedirects = new[]
  94. {
  95. // We use StartsWith below comparing these tfm, as we ignore platform versions (like, net6.0-ios16.1)
  96. ("net6.0-android", "net8.0-android"),
  97. ("net6.0-ios", "net8.0-ios"),
  98. // Designer was moved from netcoreapp to netstandard
  99. ("netcoreapp2.0", "netstandard2.0")
  100. };
  101. public static async Task ValidatePackage(
  102. Tool apiCompatTool, string packagePath, string baselineVersion,
  103. string suppressionFilesFolder, bool updateSuppressionFile)
  104. {
  105. if (!Directory.Exists(suppressionFilesFolder))
  106. {
  107. Directory.CreateDirectory(suppressionFilesFolder!);
  108. }
  109. await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion);
  110. if (baselineStream == null)
  111. return;
  112. using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read))
  113. using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read))
  114. using (Helpers.UseTempDir(out var tempFolder))
  115. {
  116. var targetDlls = GetDlls(target);
  117. var baselineDlls = GetDlls(baseline);
  118. var left = new List<string>();
  119. var right = new List<string>();
  120. var packageId = GetPackageId(packagePath);
  121. var suppressionFile = Path.Combine(suppressionFilesFolder, packageId + ".nupkg.xml");
  122. // Don't use Path.Combine with these left and right tool parameters.
  123. // Microsoft.DotNet.ApiCompat.Tool is stupid and treats '/' and '\' as different assemblies in suppression files.
  124. // So, always use Unix '/'
  125. foreach (var baselineDll in baselineDlls)
  126. {
  127. var baselineDllPath = await ExtractDll("baseline", baselineDll, tempFolder);
  128. var targetTfm = baselineDll.target;
  129. var targetDll = targetDlls.FirstOrDefault(e =>
  130. e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
  131. if (targetDll?.entry is null)
  132. {
  133. if (s_tfmRedirects.FirstOrDefault(t => baselineDll.target.StartsWith(t.oldTfm)).newTfm is {} newTfm)
  134. {
  135. targetTfm = newTfm;
  136. targetDll = targetDlls.FirstOrDefault(e =>
  137. e.target.StartsWith(targetTfm) && e.entry.Name == baselineDll.entry.Name);
  138. }
  139. }
  140. if (targetDll?.entry is null && targetDlls.Count == 1)
  141. {
  142. targetDll = targetDlls.First();
  143. Warning(
  144. $"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}." +
  145. $"Resolved: {targetDll.target} ({targetDll.entry.Name})");
  146. }
  147. if (targetDll?.entry is null)
  148. {
  149. if (packageId == "Avalonia"
  150. && baselineDll.target is "net461" or "netcoreapp2.0")
  151. {
  152. // In 11.1 we have removed net461 and netcoreapp2.0 targets from Avalonia package.
  153. continue;
  154. }
  155. var actualTargets = string.Join(", ",
  156. targetDlls.Select(d => $"{d.target} ({d.entry.Name})"));
  157. throw new InvalidOperationException(
  158. $"Some assemblies are missing in the new package {packageId}: {baselineDll.entry.Name} for {baselineDll.target}."
  159. + $"\r\nActual targets: {actualTargets}.");
  160. }
  161. var targetDllPath = await ExtractDll("target", targetDll, tempFolder);
  162. left.Add(baselineDllPath);
  163. right.Add(targetDllPath);
  164. }
  165. if (left.Any())
  166. {
  167. var args = $""" -l={string.Join(',', left)} -r="{string.Join(',', right)}" """;
  168. if (File.Exists(suppressionFile))
  169. {
  170. args += $""" --suppression-file="{suppressionFile}" """;
  171. }
  172. if (updateSuppressionFile)
  173. {
  174. args += $""" --suppression-output-file="{suppressionFile}" --generate-suppression-file=true """;
  175. }
  176. var result = apiCompatTool(args, tempFolder)
  177. .Where(t => t.Type == OutputType.Err).ToArray();
  178. if (result.Any())
  179. {
  180. throw new AggregateException(
  181. $"ApiDiffValidation task has failed for \"{Path.GetFileName(packagePath)}\" package",
  182. result.Select(r => new Exception(r.Text)));
  183. }
  184. }
  185. }
  186. }
  187. record DllEntry(string target, ZipArchiveEntry entry);
  188. static IReadOnlyCollection<DllEntry> GetDlls(ZipArchive archive)
  189. {
  190. return archive.Entries
  191. .Where(e => Path.GetExtension(e.FullName) == ".dll"
  192. // Exclude analyzers and build task, as we don't care about breaking changes there
  193. && !e.FullName.Contains("analyzers/") && !e.FullName.Contains("analyzers\\")
  194. && !e.Name.Contains("Avalonia.Build.Tasks"))
  195. .Select(e => (
  196. entry: e,
  197. isRef: e.FullName.Contains("ref/") || e.FullName.Contains("ref\\"),
  198. target: Path.GetDirectoryName(e.FullName)!.Split(new [] { '/', '\\' }).Last())
  199. )
  200. .GroupBy(e => (e.target, e.entry.Name))
  201. .Select(g => g.MaxBy(e => e.isRef))
  202. .Select(e => new DllEntry(e.target, e.entry))
  203. .ToArray();
  204. }
  205. static async Task<Stream> DownloadBaselinePackage(string packagePath, string baselineVersion)
  206. {
  207. if (baselineVersion is null)
  208. {
  209. throw new InvalidOperationException(
  210. "Build \"api-baseline\" parameter must be set when running Nuke CreatePackages");
  211. }
  212. /*
  213. Gets package name from versions like:
  214. Avalonia.0.10.0-preview1
  215. Avalonia.11.0.999-cibuild0037534-beta
  216. Avalonia.11.0.0
  217. */
  218. var packageId = GetPackageId(packagePath);
  219. Information("Downloading {0} {1} baseline package", packageId, baselineVersion);
  220. try
  221. {
  222. using var response = await s_httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
  223. $"https://www.nuget.org/api/v2/package/{packageId}/{baselineVersion}"), HttpCompletionOption.ResponseHeadersRead);
  224. response.EnsureSuccessStatusCode();
  225. await using var stream = await response.Content.ReadAsStreamAsync();
  226. var memoryStream = new MemoryStream();
  227. await stream.CopyToAsync(memoryStream);
  228. memoryStream.Seek(0, SeekOrigin.Begin);
  229. return memoryStream;
  230. }
  231. catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
  232. {
  233. return null;
  234. }
  235. catch (Exception ex)
  236. {
  237. throw new InvalidOperationException($"Downloading baseline package for {packageId} {baselineVersion} failed.\r" + ex.Message, ex);
  238. }
  239. }
  240. static async Task<string> ExtractDll(string basePath, DllEntry dllEntry, string targetFolder)
  241. {
  242. var dllPath = $"{basePath}/{dllEntry.target}/{dllEntry.entry.Name}";
  243. var dllRealPath = Path.Combine(targetFolder, dllPath);
  244. Directory.CreateDirectory(Path.GetDirectoryName(dllRealPath)!);
  245. await using (var dllFile = File.Create(dllRealPath))
  246. {
  247. await dllEntry.entry.Open().CopyToAsync(dllFile);
  248. }
  249. return dllPath;
  250. }
  251. static void GenerateApiListing(Tool apiDiffTool, string inputFile, string outputFile, string workingDif)
  252. {
  253. var args = $""" --assembly={inputFile} --output-path={outputFile} --include-assembly-attributes=true""";
  254. var result = apiDiffTool(args, workingDif)
  255. .Where(t => t.Type == OutputType.Err).ToArray();
  256. if (result.Any())
  257. {
  258. throw new AggregateException($"GetApi tool failed task has failed",
  259. result.Select(r => new Exception(r.Text)));
  260. }
  261. }
  262. static string GetPackageId(string packagePath)
  263. {
  264. return Regex.Replace(
  265. Path.GetFileNameWithoutExtension(packagePath),
  266. """(\.\d+\.\d+\.\d+(?:-.+)?)$""", "");
  267. }
  268. }