123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- #nullable enable
- using System;
- using System.Collections.Generic;
- using System.Collections.Immutable;
- using System.IO;
- using System.IO.Compression;
- using System.Linq;
- using System.Security.Cryptography;
- using System.Text.RegularExpressions;
- using System.Threading;
- using System.Threading.Tasks;
- using NuGet.Common;
- using NuGet.Configuration;
- using NuGet.Frameworks;
- using NuGet.Packaging;
- using NuGet.Protocol;
- using NuGet.Protocol.Core.Types;
- using NuGet.Versioning;
- using Nuke.Common.IO;
- using Nuke.Common.Tooling;
- using static Serilog.Log;
- public static class ApiDiffHelper
- {
- const string NightlyFeedUri = "https://nuget-feed-nightly.avaloniaui.net/v3/index.json";
- const string MainPackageName = "Avalonia";
- const string FolderLib = "lib";
- private static readonly Regex s_suppressionPathRegex =
- new("<(Left|Right)>(.*?)</(Left|Right)>", RegexOptions.Compiled);
- public static void ValidatePackage(
- Tool apiCompatTool,
- PackageDiffInfo packageDiff,
- AbsolutePath rootAssembliesFolderPath,
- AbsolutePath suppressionFilesFolderPath,
- bool updateSuppressionFile)
- {
- Information("Validating API for package {Id}", packageDiff.PackageId);
- Directory.CreateDirectory(suppressionFilesFolderPath);
- var suppressionFilePath = suppressionFilesFolderPath / (packageDiff.PackageId + ".nupkg.xml");
- var replaceDirectorySeparators = Path.DirectorySeparatorChar == '\\';
- var allErrors = new List<string>();
- foreach (var framework in packageDiff.Frameworks)
- {
- var relativeBaselinePath = rootAssembliesFolderPath.GetRelativePathTo(framework.BaselineFolderPath);
- var relativeCurrentPath = rootAssembliesFolderPath.GetRelativePathTo(framework.CurrentFolderPath);
- var args = "";
- if (suppressionFilePath.FileExists())
- {
- args += $""" --suppression-file="{suppressionFilePath}" --permit-unnecessary-suppressions """;
- if (replaceDirectorySeparators)
- ReplaceDirectorySeparators(suppressionFilePath, '/', '\\');
- }
- if (updateSuppressionFile)
- args += $""" --suppression-output-file="{suppressionFilePath}" --generate-suppression-file --preserve-unnecessary-suppressions """;
- args += $""" -l="{relativeBaselinePath}" -r="{relativeCurrentPath}" """;
- var localErrors = GetErrors(apiCompatTool($"{args:nq}", rootAssembliesFolderPath, exitHandler: _ => { }));
- if (replaceDirectorySeparators)
- ReplaceDirectorySeparators(suppressionFilePath, '\\', '/');
- allErrors.AddRange(localErrors);
- }
- ThrowOnErrors(allErrors, packageDiff.PackageId, "ValidateApiDiff");
- }
- /// <summary>
- /// The ApiCompat tool treats paths with '/' and '\' separators as different files.
- /// Before running the tool, adjust the existing separators (using a dirty regex) to match the current platform.
- /// After running the tool, change all separators back to '/'.
- /// </summary>
- static void ReplaceDirectorySeparators(AbsolutePath suppressionFilePath, char oldSeparator, char newSeparator)
- {
- if (!File.Exists(suppressionFilePath))
- return;
- var lines = File.ReadAllLines(suppressionFilePath);
- for (var i = 0; i < lines.Length; i++)
- {
- var original = lines[i];
- var replacement = s_suppressionPathRegex.Replace(original, match =>
- {
- var path = match.Groups[2].Value.Replace(oldSeparator, newSeparator);
- return $"<{match.Groups[1].Value}>{path}</{match.Groups[3].Value}>";
- });
- lines[i] = replacement;
- }
- File.WriteAllLines(suppressionFilePath, lines);
- }
- public static void GenerateMarkdownDiff(
- Tool apiDiffTool,
- PackageDiffInfo packageDiff,
- AbsolutePath rootOutputFolderPath,
- string baselineDisplay,
- string currentDisplay)
- {
- Information("Creating markdown diff for package {Id}", packageDiff.PackageId);
- var packageOutputFolderPath = rootOutputFolderPath / packageDiff.PackageId;
- Directory.CreateDirectory(packageOutputFolderPath);
- // Not specifying -eattrs incorrectly tries to load AttributesToExclude.txt, create an empty file instead.
- // See https://github.com/dotnet/sdk/issues/49719
- var excludedAttributesFilePath = (AbsolutePath)Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString());
- File.WriteAllBytes(excludedAttributesFilePath!, []);
- try
- {
- var allErrors = new List<string>();
- // The API diff tool is unbelievably slow, process in parallel.
- Parallel.ForEach(
- packageDiff.Frameworks,
- framework =>
- {
- var frameworkOutputFolderPath = packageOutputFolderPath / framework.Framework.GetShortFolderName();
- var args = $""" -b="{framework.BaselineFolderPath}" -bfn="{baselineDisplay}" -a="{framework.CurrentFolderPath}" -afn="{currentDisplay}" -o="{frameworkOutputFolderPath}" -eattrs="{excludedAttributesFilePath}" """;
- var localErrors = GetErrors(apiDiffTool($"{args:nq}"));
- if (localErrors.Length > 0)
- {
- lock (allErrors)
- allErrors.AddRange(localErrors);
- }
- });
- ThrowOnErrors(allErrors, packageDiff.PackageId, "OutputApiDiff");
- MergeFrameworkMarkdownDiffFiles(
- rootOutputFolderPath,
- packageOutputFolderPath,
- [..packageDiff.Frameworks.Select(info => info.Framework)]);
- Directory.Delete(packageOutputFolderPath, true);
- }
- finally
- {
- File.Delete(excludedAttributesFilePath);
- }
- }
- static void MergeFrameworkMarkdownDiffFiles(
- AbsolutePath rootOutputFolderPath,
- AbsolutePath packageOutputFolderPath,
- ImmutableArray<NuGetFramework> frameworks)
- {
- // At this point, the hierarchy looks like:
- // markdown/
- // ├─ net8.0/
- // │ ├─ api_diff_Avalonia.md
- // │ ├─ api_diff_Avalonia.Controls.md
- // ├─ netstandard2.0/
- // │ ├─ api_diff_Avalonia.md
- // │ ├─ api_diff_Avalonia.Controls.md
- //
- // We want one file per assembly: merge all files with the same name.
- // However, it's very likely that the diff is the same for several frameworks: in this case, keep only one file.
- var assemblyGroups = frameworks
- .SelectMany(GetFrameworkDiffFiles, (framework, filePath) => (framework, filePath))
- .GroupBy(x => x.filePath.Name)
- .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase);
- foreach (var assemblyGroup in assemblyGroups)
- {
- using var writer = File.CreateText(rootOutputFolderPath / assemblyGroup.Key.Replace("api_diff_", ""));
- var addSeparator = false;
- foreach (var similarDiffGroup in assemblyGroup.GroupBy(x => HashFile(x.filePath), ByteArrayEqualityComparer.Instance))
- {
- if (addSeparator)
- writer.WriteLine();
- using var reader = File.OpenText(similarDiffGroup.First().filePath);
- var firstLine = reader.ReadLine();
- writer.Write(firstLine);
- writer.WriteLine(" (" + string.Join(", ", similarDiffGroup.Select(x => x.framework.GetShortFolderName())) + ")");
- while (reader.ReadLine() is { } line)
- writer.WriteLine(line);
- addSeparator = true;
- }
- }
- AbsolutePath[] GetFrameworkDiffFiles(NuGetFramework framework)
- {
- var frameworkFolderPath = packageOutputFolderPath / framework.GetShortFolderName();
- if (!frameworkFolderPath.DirectoryExists())
- return [];
- return Directory.GetFiles(frameworkFolderPath, "*.md")
- .Where(filePath => Path.GetFileName(filePath) != "api_diff.md")
- .Select(filePath => (AbsolutePath)filePath)
- .ToArray();
- }
- static byte[] HashFile(AbsolutePath filePath)
- {
- using var stream = File.OpenRead(filePath);
- return SHA256.HashData(stream);
- }
- }
- public static void MergePackageMarkdownDiffFiles(
- AbsolutePath rootOutputFolderPath,
- string baselineDisplay,
- string currentDisplay)
- {
- const string mergedFileName = "_diff.md";
- var filePaths = Directory.EnumerateFiles(rootOutputFolderPath, "*.md")
- .Where(filePath => Path.GetFileName(filePath) != mergedFileName)
- .Order(StringComparer.OrdinalIgnoreCase)
- .ToArray();
- using var writer = File.CreateText(rootOutputFolderPath / mergedFileName);
- writer.WriteLine($"# API diff between {baselineDisplay} and {currentDisplay}");
- if (filePaths.Length == 0)
- {
- writer.WriteLine();
- writer.WriteLine("No changes.");
- return;
- }
- foreach (var filePath in filePaths)
- {
- writer.WriteLine();
- using var reader = File.OpenText(filePath);
- while (reader.ReadLine() is { } line)
- {
- if (line.StartsWith('#'))
- writer.Write('#');
- writer.WriteLine(line);
- }
- }
- }
- static string[] GetErrors(IEnumerable<Output> outputs)
- => outputs
- .Where(output => output.Type == OutputType.Err)
- .Select(output => output.Text)
- .ToArray();
- static void ThrowOnErrors(List<string> errors, string packageId, string taskName)
- {
- if (errors.Count > 0)
- {
- throw new AggregateException(
- $"{taskName} task has failed for \"{packageId}\" package",
- errors.Select(error => new Exception(error)));
- }
- }
- public static async Task<GlobalDiffInfo> DownloadAndExtractPackagesAsync(
- IEnumerable<AbsolutePath> currentPackagePaths,
- NuGetVersion currentVersion,
- bool isReleaseBranch,
- AbsolutePath outputFolderPath,
- NuGetVersion? forcedBaselineVersion)
- {
- var downloadContext = await CreateNuGetDownloadContextAsync();
- var baselineVersion = forcedBaselineVersion ??
- await GetBaselineVersionAsync(downloadContext, currentVersion, isReleaseBranch);
- Information("API baseline version is {Baseline} for current version {Current}", baselineVersion, currentVersion);
- var memoryStream = new MemoryStream();
- var packageDiffs = ImmutableArray.CreateBuilder<PackageDiffInfo>();
- foreach (var packagePath in currentPackagePaths)
- {
- string packageId;
- AbsolutePath currentFolderPath;
- AbsolutePath baselineFolderPath;
- Dictionary<NuGetFramework, string> currentFolderNames;
- Dictionary<NuGetFramework, string> baselineFolderNames;
- // Extract current package
- using (var currentArchive = new ZipArchive(File.OpenRead(packagePath), ZipArchiveMode.Read, leaveOpen: false))
- {
- using var packageReader = new PackageArchiveReader(currentArchive);
- packageId = packageReader.NuspecReader.GetId();
- currentFolderPath = outputFolderPath / "current" / packageId;
- currentFolderNames = ExtractDiffableAssembliesFromPackage(currentArchive, currentFolderPath);
- }
- // Download baseline package
- memoryStream.Position = 0L;
- memoryStream.SetLength(0L);
- await DownloadBaselinePackageAsync(memoryStream, downloadContext, packageId, baselineVersion);
- memoryStream.Position = 0L;
- // Extract baseline package
- using (var baselineArchive = new ZipArchive(memoryStream, ZipArchiveMode.Read, leaveOpen: true))
- {
- baselineFolderPath = outputFolderPath / "baseline" / packageId;
- baselineFolderNames = ExtractDiffableAssembliesFromPackage(baselineArchive, baselineFolderPath);
- }
- if (currentFolderNames.Count == 0 && baselineFolderNames.Count == 0)
- continue;
- var frameworkDiffs = new List<FrameworkDiffInfo>();
- // Handle frameworks that exist only in the current package.
- foreach (var framework in currentFolderNames.Keys.Except(baselineFolderNames.Keys))
- {
- var folderName = currentFolderNames[framework];
- Directory.CreateDirectory(baselineFolderPath / folderName);
- baselineFolderNames.Add(framework, folderName);
- }
- // Handle frameworks that exist only for the baseline package.
- foreach (var framework in baselineFolderNames.Keys.Except(currentFolderNames.Keys))
- {
- var folderName = baselineFolderNames[framework];
- Directory.CreateDirectory(currentFolderPath / folderName);
- currentFolderNames.Add(framework, folderName);
- }
- foreach (var (framework, currentFolderName) in currentFolderNames)
- {
- var baselineFolderName = baselineFolderNames[framework];
- frameworkDiffs.Add(new FrameworkDiffInfo(
- framework,
- baselineFolderPath / FolderLib / baselineFolderName,
- currentFolderPath / FolderLib / currentFolderName));
- }
- packageDiffs.Add(new PackageDiffInfo(packageId, [..frameworkDiffs]));
- }
- return new GlobalDiffInfo(baselineVersion, currentVersion, packageDiffs.DrainToImmutable());
- }
- static async Task<NuGetDownloadContext> CreateNuGetDownloadContextAsync()
- {
- var packageSource = new PackageSource(NightlyFeedUri) { ProtocolVersion = 3 };
- var repository = Repository.Factory.GetCoreV3(packageSource);
- var findPackageByIdResource = await repository.GetResourceAsync<FindPackageByIdResource>();
- return new NuGetDownloadContext(packageSource, findPackageByIdResource);
- }
- /// <summary>
- /// Finds the baseline version to diff against.
- /// On release branches, use the latest stable version.
- /// On the main branch and on PRs, use the latest nightly version.
- /// This method assumes all packages share the same version.
- /// </summary>
- static async Task<NuGetVersion> GetBaselineVersionAsync(
- NuGetDownloadContext context,
- NuGetVersion currentVersion,
- bool isReleaseBranch)
- {
- var versions = await context.FindPackageByIdResource.GetAllVersionsAsync(
- MainPackageName,
- context.CacheContext,
- NullLogger.Instance,
- CancellationToken.None);
- versions = versions.Where(v => v < currentVersion);
- if (isReleaseBranch)
- versions = versions.Where(v => !v.IsPrerelease);
- return versions.OrderDescending().FirstOrDefault()
- ?? throw new InvalidOperationException(
- $"Could not find a version less than {currentVersion} for package {MainPackageName} in source {context.PackageSource.Source}");
- }
- static async Task DownloadBaselinePackageAsync(
- Stream destinationStream,
- NuGetDownloadContext context,
- string packageId,
- NuGetVersion version)
- {
- Information("Downloading {Id} {Version} baseline package", packageId, version);
- var downloaded = await context.FindPackageByIdResource.CopyNupkgToStreamAsync(
- packageId,
- version,
- destinationStream,
- context.CacheContext,
- NullLogger.Instance,
- CancellationToken.None);
- if (!downloaded)
- {
- throw new InvalidOperationException(
- $"Could not download version {version} for package {packageId} in source {context.PackageSource.Source}");
- }
- }
- static Dictionary<NuGetFramework, string> ExtractDiffableAssembliesFromPackage(
- ZipArchive packageArchive,
- AbsolutePath destinationFolderPath)
- {
- var folderByFramework = new Dictionary<NuGetFramework, string>();
- foreach (var entry in packageArchive.Entries)
- {
- if (TryGetFrameworkFolderName(entry.FullName) is not { } folderName)
- continue;
- // Ignore platform versions: assume that e.g. net8.0-android34 and net8.0-android35 are the same for diff purposes.
- var framework = WithoutPlatformVersion(NuGetFramework.ParseFolder(folderName));
- if (folderByFramework.TryGetValue(framework, out var existingFolderName))
- {
- if (existingFolderName != folderName)
- {
- throw new InvalidOperationException(
- $"Found two similar frameworks with different platform versions: {existingFolderName} and {folderName}");
- }
- }
- else
- folderByFramework.Add(framework, folderName);
- var targetFilePath = destinationFolderPath / entry.FullName;
- Directory.CreateDirectory(targetFilePath.Parent);
- entry.ExtractToFile(targetFilePath, overwrite: true);
- }
- return folderByFramework;
- static string? TryGetFrameworkFolderName(string entryPath)
- {
- if (!entryPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
- return null;
- var segments = entryPath.Split('/');
- if (segments is not [FolderLib, var name, ..])
- return null;
- return name;
- }
- // e.g. net8.0-android34.0 to net8.0-android
- static NuGetFramework WithoutPlatformVersion(NuGetFramework value)
- => value.HasPlatform && value.PlatformVersion != FrameworkConstants.EmptyVersion ?
- new NuGetFramework(value.Framework, value.Version, value.Platform, FrameworkConstants.EmptyVersion) :
- value;
- }
- public sealed class GlobalDiffInfo(
- NuGetVersion baselineVersion,
- NuGetVersion currentVersion,
- ImmutableArray<PackageDiffInfo> packages)
- {
- public NuGetVersion BaselineVersion { get; } = baselineVersion;
- public NuGetVersion CurrentVersion { get; } = currentVersion;
- public ImmutableArray<PackageDiffInfo> Packages { get; } = packages;
- }
- public sealed class PackageDiffInfo(string packageId, ImmutableArray<FrameworkDiffInfo> frameworks)
- {
- public string PackageId { get; } = packageId;
- public ImmutableArray<FrameworkDiffInfo> Frameworks { get; } = frameworks;
- }
- public sealed class FrameworkDiffInfo(
- NuGetFramework framework,
- AbsolutePath baselineFolderPath,
- AbsolutePath currentFolderPath)
- {
- public NuGetFramework Framework { get; } = framework;
- public AbsolutePath BaselineFolderPath { get; } = baselineFolderPath;
- public AbsolutePath CurrentFolderPath { get; } = currentFolderPath;
- }
- sealed class NuGetDownloadContext(PackageSource packageSource, FindPackageByIdResource findPackageByIdResource)
- {
- public SourceCacheContext CacheContext { get; } = new();
- public PackageSource PackageSource { get; } = packageSource;
- public FindPackageByIdResource FindPackageByIdResource { get; } = findPackageByIdResource;
- }
- }
|