ApiDiffHelper.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. #nullable enable
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Collections.Immutable;
  5. using System.IO;
  6. using System.IO.Compression;
  7. using System.Linq;
  8. using System.Security.Cryptography;
  9. using System.Text.RegularExpressions;
  10. using System.Threading;
  11. using System.Threading.Tasks;
  12. using NuGet.Common;
  13. using NuGet.Configuration;
  14. using NuGet.Frameworks;
  15. using NuGet.Packaging;
  16. using NuGet.Protocol;
  17. using NuGet.Protocol.Core.Types;
  18. using NuGet.Versioning;
  19. using Nuke.Common.IO;
  20. using Nuke.Common.Tooling;
  21. using static Serilog.Log;
  22. public static class ApiDiffHelper
  23. {
  24. const string NightlyFeedUri = "https://nuget-feed-nightly.avaloniaui.net/v3/index.json";
  25. const string MainPackageName = "Avalonia";
  26. const string FolderLib = "lib";
  27. private static readonly Regex s_suppressionPathRegex =
  28. new("<(Left|Right)>(.*?)</(Left|Right)>", RegexOptions.Compiled);
  29. public static void ValidatePackage(
  30. Tool apiCompatTool,
  31. PackageDiffInfo packageDiff,
  32. AbsolutePath rootAssembliesFolderPath,
  33. AbsolutePath suppressionFilesFolderPath,
  34. bool updateSuppressionFile)
  35. {
  36. Information("Validating API for package {Id}", packageDiff.PackageId);
  37. Directory.CreateDirectory(suppressionFilesFolderPath);
  38. var suppressionFilePath = suppressionFilesFolderPath / (packageDiff.PackageId + ".nupkg.xml");
  39. var replaceDirectorySeparators = Path.DirectorySeparatorChar == '\\';
  40. var allErrors = new List<string>();
  41. foreach (var framework in packageDiff.Frameworks)
  42. {
  43. var relativeBaselinePath = rootAssembliesFolderPath.GetRelativePathTo(framework.BaselineFolderPath);
  44. var relativeCurrentPath = rootAssembliesFolderPath.GetRelativePathTo(framework.CurrentFolderPath);
  45. var args = "";
  46. if (suppressionFilePath.FileExists())
  47. {
  48. args += $""" --suppression-file="{suppressionFilePath}" --permit-unnecessary-suppressions """;
  49. if (replaceDirectorySeparators)
  50. ReplaceDirectorySeparators(suppressionFilePath, '/', '\\');
  51. }
  52. if (updateSuppressionFile)
  53. args += $""" --suppression-output-file="{suppressionFilePath}" --generate-suppression-file --preserve-unnecessary-suppressions """;
  54. args += $""" -l="{relativeBaselinePath}" -r="{relativeCurrentPath}" """;
  55. var localErrors = GetErrors(apiCompatTool($"{args:nq}", rootAssembliesFolderPath, exitHandler: _ => { }));
  56. if (replaceDirectorySeparators)
  57. ReplaceDirectorySeparators(suppressionFilePath, '\\', '/');
  58. allErrors.AddRange(localErrors);
  59. }
  60. ThrowOnErrors(allErrors, packageDiff.PackageId, "ValidateApiDiff");
  61. }
  62. /// <summary>
  63. /// The ApiCompat tool treats paths with '/' and '\' separators as different files.
  64. /// Before running the tool, adjust the existing separators (using a dirty regex) to match the current platform.
  65. /// After running the tool, change all separators back to '/'.
  66. /// </summary>
  67. static void ReplaceDirectorySeparators(AbsolutePath suppressionFilePath, char oldSeparator, char newSeparator)
  68. {
  69. if (!File.Exists(suppressionFilePath))
  70. return;
  71. var lines = File.ReadAllLines(suppressionFilePath);
  72. for (var i = 0; i < lines.Length; i++)
  73. {
  74. var original = lines[i];
  75. var replacement = s_suppressionPathRegex.Replace(original, match =>
  76. {
  77. var path = match.Groups[2].Value.Replace(oldSeparator, newSeparator);
  78. return $"<{match.Groups[1].Value}>{path}</{match.Groups[3].Value}>";
  79. });
  80. lines[i] = replacement;
  81. }
  82. File.WriteAllLines(suppressionFilePath, lines);
  83. }
  84. public static void GenerateMarkdownDiff(
  85. Tool apiDiffTool,
  86. PackageDiffInfo packageDiff,
  87. AbsolutePath rootOutputFolderPath,
  88. string baselineDisplay,
  89. string currentDisplay)
  90. {
  91. Information("Creating markdown diff for package {Id}", packageDiff.PackageId);
  92. var packageOutputFolderPath = rootOutputFolderPath / packageDiff.PackageId;
  93. Directory.CreateDirectory(packageOutputFolderPath);
  94. // Not specifying -eattrs incorrectly tries to load AttributesToExclude.txt, create an empty file instead.
  95. // See https://github.com/dotnet/sdk/issues/49719
  96. var excludedAttributesFilePath = (AbsolutePath)Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString());
  97. File.WriteAllBytes(excludedAttributesFilePath!, []);
  98. try
  99. {
  100. var allErrors = new List<string>();
  101. // The API diff tool is unbelievably slow, process in parallel.
  102. Parallel.ForEach(
  103. packageDiff.Frameworks,
  104. framework =>
  105. {
  106. var frameworkOutputFolderPath = packageOutputFolderPath / framework.Framework.GetShortFolderName();
  107. var args = $""" -b="{framework.BaselineFolderPath}" -bfn="{baselineDisplay}" -a="{framework.CurrentFolderPath}" -afn="{currentDisplay}" -o="{frameworkOutputFolderPath}" -eattrs="{excludedAttributesFilePath}" """;
  108. var localErrors = GetErrors(apiDiffTool($"{args:nq}"));
  109. if (localErrors.Length > 0)
  110. {
  111. lock (allErrors)
  112. allErrors.AddRange(localErrors);
  113. }
  114. });
  115. ThrowOnErrors(allErrors, packageDiff.PackageId, "OutputApiDiff");
  116. MergeFrameworkMarkdownDiffFiles(
  117. rootOutputFolderPath,
  118. packageOutputFolderPath,
  119. [..packageDiff.Frameworks.Select(info => info.Framework)]);
  120. Directory.Delete(packageOutputFolderPath, true);
  121. }
  122. finally
  123. {
  124. File.Delete(excludedAttributesFilePath);
  125. }
  126. }
  127. static void MergeFrameworkMarkdownDiffFiles(
  128. AbsolutePath rootOutputFolderPath,
  129. AbsolutePath packageOutputFolderPath,
  130. ImmutableArray<NuGetFramework> frameworks)
  131. {
  132. // At this point, the hierarchy looks like:
  133. // markdown/
  134. // ├─ net8.0/
  135. // │ ├─ api_diff_Avalonia.md
  136. // │ ├─ api_diff_Avalonia.Controls.md
  137. // ├─ netstandard2.0/
  138. // │ ├─ api_diff_Avalonia.md
  139. // │ ├─ api_diff_Avalonia.Controls.md
  140. //
  141. // We want one file per assembly: merge all files with the same name.
  142. // However, it's very likely that the diff is the same for several frameworks: in this case, keep only one file.
  143. var assemblyGroups = frameworks
  144. .SelectMany(GetFrameworkDiffFiles, (framework, filePath) => (framework, filePath))
  145. .GroupBy(x => x.filePath.Name)
  146. .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase);
  147. foreach (var assemblyGroup in assemblyGroups)
  148. {
  149. using var writer = File.CreateText(rootOutputFolderPath / assemblyGroup.Key.Replace("api_diff_", ""));
  150. var addSeparator = false;
  151. foreach (var similarDiffGroup in assemblyGroup.GroupBy(x => HashFile(x.filePath), ByteArrayEqualityComparer.Instance))
  152. {
  153. if (addSeparator)
  154. writer.WriteLine();
  155. using var reader = File.OpenText(similarDiffGroup.First().filePath);
  156. var firstLine = reader.ReadLine();
  157. writer.Write(firstLine);
  158. writer.WriteLine(" (" + string.Join(", ", similarDiffGroup.Select(x => x.framework.GetShortFolderName())) + ")");
  159. while (reader.ReadLine() is { } line)
  160. writer.WriteLine(line);
  161. addSeparator = true;
  162. }
  163. }
  164. AbsolutePath[] GetFrameworkDiffFiles(NuGetFramework framework)
  165. {
  166. var frameworkFolderPath = packageOutputFolderPath / framework.GetShortFolderName();
  167. if (!frameworkFolderPath.DirectoryExists())
  168. return [];
  169. return Directory.GetFiles(frameworkFolderPath, "*.md")
  170. .Where(filePath => Path.GetFileName(filePath) != "api_diff.md")
  171. .Select(filePath => (AbsolutePath)filePath)
  172. .ToArray();
  173. }
  174. static byte[] HashFile(AbsolutePath filePath)
  175. {
  176. using var stream = File.OpenRead(filePath);
  177. return SHA256.HashData(stream);
  178. }
  179. }
  180. public static void MergePackageMarkdownDiffFiles(
  181. AbsolutePath rootOutputFolderPath,
  182. string baselineDisplay,
  183. string currentDisplay)
  184. {
  185. const string mergedFileName = "_diff.md";
  186. var filePaths = Directory.EnumerateFiles(rootOutputFolderPath, "*.md")
  187. .Where(filePath => Path.GetFileName(filePath) != mergedFileName)
  188. .Order(StringComparer.OrdinalIgnoreCase)
  189. .ToArray();
  190. using var writer = File.CreateText(rootOutputFolderPath / mergedFileName);
  191. writer.WriteLine($"# API diff between {baselineDisplay} and {currentDisplay}");
  192. if (filePaths.Length == 0)
  193. {
  194. writer.WriteLine();
  195. writer.WriteLine("No changes.");
  196. return;
  197. }
  198. foreach (var filePath in filePaths)
  199. {
  200. writer.WriteLine();
  201. using var reader = File.OpenText(filePath);
  202. while (reader.ReadLine() is { } line)
  203. {
  204. if (line.StartsWith('#'))
  205. writer.Write('#');
  206. writer.WriteLine(line);
  207. }
  208. }
  209. }
  210. static string[] GetErrors(IEnumerable<Output> outputs)
  211. => outputs
  212. .Where(output => output.Type == OutputType.Err)
  213. .Select(output => output.Text)
  214. .ToArray();
  215. static void ThrowOnErrors(List<string> errors, string packageId, string taskName)
  216. {
  217. if (errors.Count > 0)
  218. {
  219. throw new AggregateException(
  220. $"{taskName} task has failed for \"{packageId}\" package",
  221. errors.Select(error => new Exception(error)));
  222. }
  223. }
  224. public static async Task<GlobalDiffInfo> DownloadAndExtractPackagesAsync(
  225. IEnumerable<AbsolutePath> currentPackagePaths,
  226. NuGetVersion currentVersion,
  227. bool isReleaseBranch,
  228. AbsolutePath outputFolderPath,
  229. NuGetVersion? forcedBaselineVersion)
  230. {
  231. var downloadContext = await CreateNuGetDownloadContextAsync();
  232. var baselineVersion = forcedBaselineVersion ??
  233. await GetBaselineVersionAsync(downloadContext, currentVersion, isReleaseBranch);
  234. Information("API baseline version is {Baseline} for current version {Current}", baselineVersion, currentVersion);
  235. var memoryStream = new MemoryStream();
  236. var packageDiffs = ImmutableArray.CreateBuilder<PackageDiffInfo>();
  237. foreach (var packagePath in currentPackagePaths)
  238. {
  239. string packageId;
  240. AbsolutePath currentFolderPath;
  241. AbsolutePath baselineFolderPath;
  242. Dictionary<NuGetFramework, string> currentFolderNames;
  243. Dictionary<NuGetFramework, string> baselineFolderNames;
  244. // Extract current package
  245. using (var currentArchive = new ZipArchive(File.OpenRead(packagePath), ZipArchiveMode.Read, leaveOpen: false))
  246. {
  247. using var packageReader = new PackageArchiveReader(currentArchive);
  248. packageId = packageReader.NuspecReader.GetId();
  249. currentFolderPath = outputFolderPath / "current" / packageId;
  250. currentFolderNames = ExtractDiffableAssembliesFromPackage(currentArchive, currentFolderPath);
  251. }
  252. // Download baseline package
  253. memoryStream.Position = 0L;
  254. memoryStream.SetLength(0L);
  255. await DownloadBaselinePackageAsync(memoryStream, downloadContext, packageId, baselineVersion);
  256. memoryStream.Position = 0L;
  257. // Extract baseline package
  258. using (var baselineArchive = new ZipArchive(memoryStream, ZipArchiveMode.Read, leaveOpen: true))
  259. {
  260. baselineFolderPath = outputFolderPath / "baseline" / packageId;
  261. baselineFolderNames = ExtractDiffableAssembliesFromPackage(baselineArchive, baselineFolderPath);
  262. }
  263. if (currentFolderNames.Count == 0 && baselineFolderNames.Count == 0)
  264. continue;
  265. var frameworkDiffs = new List<FrameworkDiffInfo>();
  266. // Handle frameworks that exist only in the current package.
  267. foreach (var framework in currentFolderNames.Keys.Except(baselineFolderNames.Keys))
  268. {
  269. var folderName = currentFolderNames[framework];
  270. Directory.CreateDirectory(baselineFolderPath / folderName);
  271. baselineFolderNames.Add(framework, folderName);
  272. }
  273. // Handle frameworks that exist only for the baseline package.
  274. foreach (var framework in baselineFolderNames.Keys.Except(currentFolderNames.Keys))
  275. {
  276. var folderName = baselineFolderNames[framework];
  277. Directory.CreateDirectory(currentFolderPath / folderName);
  278. currentFolderNames.Add(framework, folderName);
  279. }
  280. foreach (var (framework, currentFolderName) in currentFolderNames)
  281. {
  282. var baselineFolderName = baselineFolderNames[framework];
  283. frameworkDiffs.Add(new FrameworkDiffInfo(
  284. framework,
  285. baselineFolderPath / FolderLib / baselineFolderName,
  286. currentFolderPath / FolderLib / currentFolderName));
  287. }
  288. packageDiffs.Add(new PackageDiffInfo(packageId, [..frameworkDiffs]));
  289. }
  290. return new GlobalDiffInfo(baselineVersion, currentVersion, packageDiffs.DrainToImmutable());
  291. }
  292. static async Task<NuGetDownloadContext> CreateNuGetDownloadContextAsync()
  293. {
  294. var packageSource = new PackageSource(NightlyFeedUri) { ProtocolVersion = 3 };
  295. var repository = Repository.Factory.GetCoreV3(packageSource);
  296. var findPackageByIdResource = await repository.GetResourceAsync<FindPackageByIdResource>();
  297. return new NuGetDownloadContext(packageSource, findPackageByIdResource);
  298. }
  299. /// <summary>
  300. /// Finds the baseline version to diff against.
  301. /// On release branches, use the latest stable version.
  302. /// On the main branch and on PRs, use the latest nightly version.
  303. /// This method assumes all packages share the same version.
  304. /// </summary>
  305. static async Task<NuGetVersion> GetBaselineVersionAsync(
  306. NuGetDownloadContext context,
  307. NuGetVersion currentVersion,
  308. bool isReleaseBranch)
  309. {
  310. var versions = await context.FindPackageByIdResource.GetAllVersionsAsync(
  311. MainPackageName,
  312. context.CacheContext,
  313. NullLogger.Instance,
  314. CancellationToken.None);
  315. versions = versions.Where(v => v < currentVersion);
  316. if (isReleaseBranch)
  317. versions = versions.Where(v => !v.IsPrerelease);
  318. return versions.OrderDescending().FirstOrDefault()
  319. ?? throw new InvalidOperationException(
  320. $"Could not find a version less than {currentVersion} for package {MainPackageName} in source {context.PackageSource.Source}");
  321. }
  322. static async Task DownloadBaselinePackageAsync(
  323. Stream destinationStream,
  324. NuGetDownloadContext context,
  325. string packageId,
  326. NuGetVersion version)
  327. {
  328. Information("Downloading {Id} {Version} baseline package", packageId, version);
  329. var downloaded = await context.FindPackageByIdResource.CopyNupkgToStreamAsync(
  330. packageId,
  331. version,
  332. destinationStream,
  333. context.CacheContext,
  334. NullLogger.Instance,
  335. CancellationToken.None);
  336. if (!downloaded)
  337. {
  338. throw new InvalidOperationException(
  339. $"Could not download version {version} for package {packageId} in source {context.PackageSource.Source}");
  340. }
  341. }
  342. static Dictionary<NuGetFramework, string> ExtractDiffableAssembliesFromPackage(
  343. ZipArchive packageArchive,
  344. AbsolutePath destinationFolderPath)
  345. {
  346. var folderByFramework = new Dictionary<NuGetFramework, string>();
  347. foreach (var entry in packageArchive.Entries)
  348. {
  349. if (TryGetFrameworkFolderName(entry.FullName) is not { } folderName)
  350. continue;
  351. // Ignore platform versions: assume that e.g. net8.0-android34 and net8.0-android35 are the same for diff purposes.
  352. var framework = WithoutPlatformVersion(NuGetFramework.ParseFolder(folderName));
  353. if (folderByFramework.TryGetValue(framework, out var existingFolderName))
  354. {
  355. if (existingFolderName != folderName)
  356. {
  357. throw new InvalidOperationException(
  358. $"Found two similar frameworks with different platform versions: {existingFolderName} and {folderName}");
  359. }
  360. }
  361. else
  362. folderByFramework.Add(framework, folderName);
  363. var targetFilePath = destinationFolderPath / entry.FullName;
  364. Directory.CreateDirectory(targetFilePath.Parent);
  365. entry.ExtractToFile(targetFilePath, overwrite: true);
  366. }
  367. return folderByFramework;
  368. static string? TryGetFrameworkFolderName(string entryPath)
  369. {
  370. if (!entryPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
  371. return null;
  372. var segments = entryPath.Split('/');
  373. if (segments is not [FolderLib, var name, ..])
  374. return null;
  375. return name;
  376. }
  377. // e.g. net8.0-android34.0 to net8.0-android
  378. static NuGetFramework WithoutPlatformVersion(NuGetFramework value)
  379. => value.HasPlatform && value.PlatformVersion != FrameworkConstants.EmptyVersion ?
  380. new NuGetFramework(value.Framework, value.Version, value.Platform, FrameworkConstants.EmptyVersion) :
  381. value;
  382. }
  383. public sealed class GlobalDiffInfo(
  384. NuGetVersion baselineVersion,
  385. NuGetVersion currentVersion,
  386. ImmutableArray<PackageDiffInfo> packages)
  387. {
  388. public NuGetVersion BaselineVersion { get; } = baselineVersion;
  389. public NuGetVersion CurrentVersion { get; } = currentVersion;
  390. public ImmutableArray<PackageDiffInfo> Packages { get; } = packages;
  391. }
  392. public sealed class PackageDiffInfo(string packageId, ImmutableArray<FrameworkDiffInfo> frameworks)
  393. {
  394. public string PackageId { get; } = packageId;
  395. public ImmutableArray<FrameworkDiffInfo> Frameworks { get; } = frameworks;
  396. }
  397. public sealed class FrameworkDiffInfo(
  398. NuGetFramework framework,
  399. AbsolutePath baselineFolderPath,
  400. AbsolutePath currentFolderPath)
  401. {
  402. public NuGetFramework Framework { get; } = framework;
  403. public AbsolutePath BaselineFolderPath { get; } = baselineFolderPath;
  404. public AbsolutePath CurrentFolderPath { get; } = currentFolderPath;
  405. }
  406. sealed class NuGetDownloadContext(PackageSource packageSource, FindPackageByIdResource findPackageByIdResource)
  407. {
  408. public SourceCacheContext CacheContext { get; } = new();
  409. public PackageSource PackageSource { get; } = packageSource;
  410. public FindPackageByIdResource FindPackageByIdResource { get; } = findPackageByIdResource;
  411. }
  412. }