diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj
new file mode 100644
index 000000000..878b136be
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Library
+ netstandard2.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);NU5128;NU5129;NU5100
+ tasks
+ true
+
+
+
+
+
+
+
+
+
+
+
+ <_DiffToolPublishContent Include="$(OutputPath)PackageDiff\**" />
+
+
+
+
+
diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs
new file mode 100644
index 000000000..744779cce
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Build.Framework;
+
+public class PackageDiff: Microsoft.Build.Utilities.ToolTask
+{
+ [Required]
+ public string BaselinePackage {get; set;} = "";
+
+ [Required]
+ public string TestPackage {get; set;} = "";
+
+ protected override string ToolName { get; } = $"PackageDiff" + (System.Environment.OSVersion.Platform == PlatformID.Unix ? "" : ".exe");
+
+ protected override MessageImportance StandardOutputLoggingImportance => MessageImportance.High;
+ protected override bool HandleTaskExecutionErrors() => true;
+
+ protected override string GenerateFullPathToTool()
+ {
+ return Path.Combine(Path.GetDirectoryName(typeof(PackageDiff).Assembly.Location)!, "..", "..", "tools", ToolName);
+ }
+
+ protected override string GenerateCommandLineCommands()
+ {
+ return $"\"{BaselinePackage}\" \"{TestPackage}\"";
+ }
+}
diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props
new file mode 100644
index 000000000..239643148
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props
@@ -0,0 +1,9 @@
+
+
+
+ <_PackageDiffTasksAssemblyPath>$(MSBuildThisFileDirectory)\..\tasks\netstandard2.0\PackageDiff.Tasks.dll
+
+
+
+
+
diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj
new file mode 100644
index 000000000..c74b0b783
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj
@@ -0,0 +1,11 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ true
+ true
+
+
+
diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs
new file mode 100644
index 000000000..924c0aba6
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs
@@ -0,0 +1,160 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Net.Http;
+using System.Reflection;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using System.Text;
+using System.Threading.Tasks;
+
+public class PackageDiff
+{
+ public static async Task Main(string[] args)
+ {
+ if (args.Length != 2)
+ {
+ Console.WriteLine("Usage: PackageDiff ");
+ return 1;
+ }
+
+ ZipArchive package1 = await GetZipArchiveAsync(args[0]);
+ ZipArchive package2 = await GetZipArchiveAsync(args[1]);
+ var diff = GetDiffs(package1, package2);
+ if (diff is not "")
+ {
+ Console.WriteLine(diff);
+ return 1;
+ }
+ return 0;
+ }
+
+ public static async Task GetZipArchiveAsync(string arg)
+ {
+ if (File.Exists(arg))
+ {
+ return new ZipArchive(File.OpenRead(arg));
+ }
+ else if (Uri.TryCreate(arg, UriKind.RelativeOrAbsolute, out var uri))
+ {
+ var webClient = new HttpClient();
+ return new ZipArchive(await webClient.GetStreamAsync(uri));
+ }
+ else
+ {
+ throw new ArgumentException($"Invalid path or url to package1: {arg}");
+ }
+ }
+
+ public static string GetDiffs(ZipArchive package1, ZipArchive package2)
+ {
+ StringBuilder output = new();
+
+ if (TryGetDiff(package1.Entries.Select(entry => entry.FullName).ToList(), package2.Entries.Select(entry => entry.FullName).ToList(), out var fileDiffs))
+ {
+ output.AppendLine("File differences:");
+ output.AppendLine(string.Join(Environment.NewLine, fileDiffs.Select(d => " " + d)));
+ output.AppendLine();
+ }
+
+ if (TryGetDiff(package1.GetNuspec().Lines(), package2.GetNuspec().Lines(), out var editedDiff))
+ {
+ output.AppendLine("Nuspec differences:");
+ output.AppendLine(string.Join(Environment.NewLine, editedDiff.Select(d => " " + d)));
+ output.AppendLine();
+ }
+ var dlls1 = package1.Entries.Where(entry => entry.FullName.EndsWith(".dll")).ToImmutableDictionary(entry => entry.FullName, entry => entry);
+ var dlls2 = package2.Entries.Where(entry => entry.FullName.EndsWith(".dll")).ToImmutableDictionary(entry => entry.FullName, entry => entry);
+ foreach (var kvp in dlls1)
+ {
+ var dllPath = kvp.Key;
+ var dll1 = kvp.Value;
+ if (dlls2.TryGetValue(dllPath, out ZipArchiveEntry? dll2))
+ {
+ try
+ {
+ var version1 = new PEReader(dll1.Open().ReadToEnd().ToImmutableArray()).GetMetadataReader().GetAssemblyDefinition().Version.ToString();
+ var version2 = new PEReader(dll2.Open().ReadToEnd().ToImmutableArray()).GetMetadataReader().GetAssemblyDefinition().Version.ToString();
+ if (version1 != version2)
+ {
+ output.AppendLine($"Assembly {dllPath} has different versions: {version1} and {version2}");
+ }
+ }
+ catch (InvalidOperationException)
+ { }
+ }
+ }
+ return output.ToString();
+ }
+
+ public static bool TryGetDiff(List originalLines, List modifiedLines, out List formattedDiff)
+ {
+ // Edit distance algorithm: https://en.wikipedia.org/wiki/Longest_common_subsequence
+
+ int[,] dp = new int[originalLines.Count + 1, modifiedLines.Count + 1];
+
+ // Initialize first row and column
+ for (int i = 0; i <= originalLines.Count; i++)
+ {
+ dp[i, 0] = i;
+ }
+ for (int j = 0; j <= modifiedLines.Count; j++)
+ {
+ dp[0, j] = j;
+ }
+
+ // Compute edit distance
+ for (int i = 1; i <= originalLines.Count; i++)
+ {
+ for (int j = 1; j <= modifiedLines.Count; j++)
+ {
+ if (string.Compare(originalLines[i - 1], modifiedLines[j - 1]) == 0)
+ {
+ dp[i, j] = dp[i - 1, j - 1];
+ }
+ else
+ {
+ dp[i, j] = 1 + Math.Min(dp[i - 1, j], dp[i, j - 1]);
+ }
+ }
+ }
+
+ // Trace back the edits
+ int row = originalLines.Count;
+ int col = modifiedLines.Count;
+
+ formattedDiff = [];
+ while (row > 0 || col > 0)
+ {
+ if (row > 0 && col > 0 && string.Compare(originalLines[row - 1], modifiedLines[col - 1]) == 0)
+ {
+ formattedDiff.Add(" " + originalLines[row - 1]);
+ row--;
+ col--;
+ }
+ else if (col > 0 && (row == 0 || dp[row, col - 1] <= dp[row - 1, col]))
+ {
+ formattedDiff.Add("+ " + modifiedLines[col - 1]);
+ col--;
+ }
+ else if (row > 0 && (col == 0 || dp[row, col - 1] > dp[row - 1, col]))
+ {
+ formattedDiff.Add("- " + originalLines[row - 1]);
+ row--;
+ }
+ else
+ {
+ throw new Exception("Unreachable code");
+ }
+ }
+ formattedDiff.Reverse();
+ return dp[originalLines.Count, modifiedLines.Count] != 0;
+ }
+
+}
diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs
new file mode 100644
index 000000000..914740b06
--- /dev/null
+++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs
@@ -0,0 +1,60 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+
+static class ZipExtensions
+{
+ public static List Lines(this ZipArchiveEntry entry, Encoding? encoding = null)
+ {
+ return entry.ReadToString(encoding).Replace("\r\n", "\n").Split('\n').ToList();
+ }
+
+ public static string ReadToString(this ZipArchiveEntry entry, Encoding? encoding = null)
+ {
+ Stream stream = entry.Open();
+ byte[] buffer = stream.ReadToEnd();
+ // Remove UTF-8 BOM if present
+ int index = 0;
+ if (buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF)
+ {
+ index = 3;
+ }
+ encoding ??= Encoding.UTF8;
+ string fileText = encoding.GetString(buffer, index, buffer.Length - index);
+ return fileText;
+ }
+
+ public static ZipArchiveEntry GetNuspec(this ZipArchive package)
+ {
+ return package.Entries.Where(entry => entry.FullName.EndsWith(".nuspec")).Single();
+ }
+
+ public static byte[] ReadToEnd(this Stream stream)
+ {
+ int bufferSize = 2048;
+ byte[] buffer = new byte[bufferSize];
+ int offset = 0;
+ while (true)
+ {
+ int bytesRead = stream.Read(buffer, offset, bufferSize - offset);
+ offset += bytesRead;
+ if (bytesRead == 0)
+ {
+ break;
+ }
+ if (offset == bufferSize)
+ {
+ Array.Resize(ref buffer, bufferSize * 2);
+ bufferSize *= 2;
+ }
+ }
+ Array.Resize(ref buffer, offset);
+ return buffer;
+ }
+}