Diff the public API of two versions of your assemblies or packages, find out whether the change is breaking, and see what a package update would break in your own code: for people, pull requests and coding agents.
Proc 0.14.0 added a CancellationToken to its Start* methods. Diffing it against 0.13.0 straight from nuget.org shows
each old overload deleted and its replacement added, which makes the release a major change:
$ assembly-differ nuget Proc 0.13.0 0.14.0 -t proc -f xml
[diff] Difference found: NuGetAssemblyProvider:proc.dll and NuGetAssemblyProvider:proc.dll
<Assembly Name="proc, Version=0.0.0.0, Culture=neutral, PublicKeyToken=96c599bbe3e70f5d" DiffType="Modified">
<Module Name="proc.dll" DiffType="Modified">
<Type Name="ProcNet.LongRunningApplicationSubscription" DiffType="Modified">
<Property Name="WaitHandle" DiffType="Modified">
<Method Name="get" DiffType="Modified">
<DeclarationDiffs>
<DiffItem DiffType="Modified">Member is more visible.</DiffItem>
</DeclarationDiffs>
</Method>
</Property>
</Type>
<Type Name="ProcNet.ObservableProcessBase<TConsoleOut>" DiffType="Modified">
<Method Name="WaitForCompletion(Nullable<TimeSpan>, CancellationToken)" DiffType="New" />
<Method Name="WaitForCompletion(Nullable<TimeSpan>)" DiffType="Deleted" />
</Type>
<Type Name="ProcNet.Proc" DiffType="Modified">
<Method Name="Start(StartArguments, CancellationToken)" DiffType="New" />
<Method Name="Start(StartArguments)" DiffType="Deleted" />
<Method Name="StartLongRunning(LongRunningArguments, TimeSpan, IConsoleOutWriter, CancellationToken)" DiffType="New" />
<Method Name="StartLongRunning(LongRunningArguments, TimeSpan, IConsoleOutWriter)" DiffType="Deleted" />
<Method Name="StartRedirected(StartArguments, StandardInputHandler, IConsoleLineHandler, CancellationToken)" DiffType="New" />
<Method Name="StartRedirected(StartArguments, StandardInputHandler, IConsoleLineHandler)" DiffType="Deleted" />
<Method Name="StartRedirected(StartArguments, IConsoleLineHandler, CancellationToken)" DiffType="New" />
<Method Name="StartRedirected(StartArguments, IConsoleLineHandler)" DiffType="Deleted" />
</Type>
</Module>
</Assembly>[diff] Suggested version change: Majordotnet tool install --global assembly-differOn Linux (x64, arm64), Windows (x64, arm64) and macOS (arm64) this installs a self-contained native-AOT executable: no shared .NET runtime, no JIT warm-up. Everywhere else it falls back to a framework-dependent build, which needs the .NET 11 runtime installed.
An agent asked to update a package, or to change a library, usually has to decompile assemblies and read release notes
to find out what changes. assembly-differ answers that from the metadata, in one command, and in a solution it narrows
the answer to the API the code actually uses:
dotnet build && assembly-differ nuget Polly 8.5.0 -f json -o . # what updating Polly to 8.5.0 breaks in this code
dotnet build -c Release && assembly-differ -f json -o . # what my changes did to this repo's librariesapi-changes.json holds the suggested version change, the number of breaking changes and every change as a tree. The
assembly-differ.skills package carries an agent skill that teaches all of
this: add it to a project next to installing the tool, and dotnet restore puts skills/assembly-differ/SKILL.md on
the machine for skill loaders such as dotnet-package-skills to copy into .agents/skills.
dotnet tool install --global assembly-differ
dotnet add package assembly-differ.skillsIn a repository, build it, then run the tool without a command. It compares every library project with the latest release of its package:
dotnet build -c Release && assembly-differIt reads the one solution in the directory, or else the project files in it, and skips test projects and projects with
IsPackable set to false (--include-tests and --include-unpackable compare them, and --project picks
projects). It does not build: a project without Release output is skipped with a warning.
Compare the package you just packed with its last release, and fail the build on a breaking change:
assembly-differ release build/output/MyLib.1.3.0.nupkg --fail-on majorThe package id and version come from the .nupkg, and the release compared with is the newest stable one before
1.3.0 on the feeds your NuGet.config lists, so the same command keeps working as you release. A package with no
earlier release is a first release: there is nothing to compare, so the command says so and succeeds, unless you pass
--require-baseline.
| Command | Compares |
|---|---|
release <path> |
a new build, as a .nupkg, a .dll or a directory, with the release before it; pass --package and --version unless <path> is a .nupkg |
files <old> <new> |
two local builds, each a .dll, a directory of dlls or a .nupkg |
api [path] |
nothing: writes the public API as .api files, and --verify checks committed ones against the build (see Public API files) |
upgrades |
in a solution, every package it references directly that has a newer release, and what updating each breaks in your code |
nuget <id> [<version>] [<new-version>] |
in a solution, the version your projects resolve with the latest release, or with <version>: what updating would change; anywhere, two published versions |
projects |
every library project in the directory against its latest release; what runs without a command |
Every command takes the same options:
| Option | What |
|---|---|
-t, --target |
only compare these assemblies, by file name without extension (repeatable); defaults to every assembly both sides have |
-f, --format |
markdown (default), xml, asciidoc, github-comment (a pull request comment with a collapsible diff per assembly) or json (api-changes.json: the suggested change, breaking changes and every change as a tree, for tools) |
-o, --output |
a directory or file to write the report to, besides the console |
--fail-on |
exit with 4 when the suggested change is at least patch, minor or major; none (the default) never fails |
--framework |
the target framework to compare, such as net8.0 (files, nuget and release); defaults to the newest the packages share |
To compare with an older commit, check it out and build it next to your working copy, then compare the two builds:
git worktree add ../old v1.2.0 && dotnet build ../old -c Release
assembly-differ files ../old/src/MyLib/bin/Release/net8.0 src/MyLib/bin/Release/net8.0In a solution, nuget shows only the changes to the types and members your built projects reference: a removed
method you call, not the hundred you don't. It reads that from your build output, so build first; without a build it
shows every change, with a warning, and --all always does. Members match by name, and a constant you use does not show,
because the compiler copies its value into your code.
Packages download into NuGet's global packages folder, as dotnet restore does, so a second run downloads nothing.
What the tool unpacks and copies to compare goes into a scratch folder of the run's own, under the system's temporary
folder, and is deleted when the run ends; set ASSEMBLY_DIFFER_KEEP_SCRATCH=1 to keep it.
A single assembly on each side is compared whatever its file name. With more, assemblies pair by file name, and one
only a side has is printed; it counts as removed or added API when --target names it.
The suggested change follows semantic versioning. It is None when the public API is unchanged, Patch when only
assembly references changed, and Minor when API was only added or attributes changed: Proc 0.13.0 marks
NoWrapInThread [Obsolete], and assembly-differ nuget Proc 0.12.0 0.13.0 suggests Minor. It is Major when
something was removed or changed, and when a new interface member, abstract member or required member makes existing
code change.
More examples:
# two local builds, every dll matched by name
assembly-differ files build/1.2.0 build/1.3.0
# what updating Polly from the version this solution resolves to 8.5.0 would change for this code
dotnet build && assembly-differ nuget Polly 8.5.0
# two published packages, as Markdown
assembly-differ nuget Proc 0.13.0 0.14.0 -t proc
# the release before 1.3.0 against a build directory
assembly-differ release src/MyLib/bin/Release/net8.0 --package MyLib --version 1.3.0 -t MyLibpull-request puts a report on the pull request a GitHub Actions run is for. Write the report with --format json
first:
assembly-differ release build/output/MyLib.1.3.0.nupkg -f json -o build/output
assembly-differ pull-request build/output/api-changes.jsonIt writes the job summary, keeps one comment on the pull request up to date, and sets the release label the suggested
change calls for: breaking for Major and feature for Minor, the labels release-drafter turns into the next
version (--major-label, --minor-label and --patch-label rename them). It records the labels it set in its comment,
and a later run removes only those, never a label a person added. It needs GITHUB_TOKEN with pull-requests: write.
Without write access, as on a pull request from a fork, it writes the job summary, warns and succeeds.
With --fail-on major, a breaking change fails the check, and the comment says how to go ahead: add the
breaking-allowed label (--allow-label renames it). Adding the label runs the workflow again, provided it runs on
the labeled and unlabeled events, and the check passes with the change labelled breaking. So a breaking change is
caught, labelled for release-drafter, and merged only once someone says it is intended.
Before it had a command per source, the tool took two provider specs, and diff still does, unchanged, for the builds
that call it: assembly-differ diff "previous-nuget|MyLib|1.3.0|net8.0" "directory|bin" -t MyLib -p major. A spec is
assembly|<path>, directory|<path>, nuget|<id>|<version>|[tfm], previous-nuget|<id>|<version>|[tfm] or
github|<owner/repo>|<commit>|<build command>|<relative output path>; github is deprecated in favour of the git worktree recipe above. Its options are -t, -o, -f (default
xml), -p/--prevent-change in place of --fail-on, and -a/--allow-empty-previous-nuget, which succeeds when
previous-nuget finds no earlier release.
On every pull request, report what it changes in your libraries' public API, label it for release-drafter, and fail
on a breaking change until someone adds the breaking-allowed label:
name: api-changes
on:
pull_request:
# labeled and unlabeled run the check again when someone adds or removes breaking-allowed
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: write # the comment and the labels
jobs:
api-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet build -c Release
- uses: nullean/assembly-differ@v1Without package, the action compares every library project of the solution with the latest release of its package,
the way running the tool without a command does. Give package: build/output/MyLib.1.3.0.nupkg to compare a packed
package with the release before it instead. It installs the tool with dotnet tool, so it needs the .NET 10 SDK or
later on the runner, the one your build uses.
| Input | Default | What |
|---|---|---|
package |
a .nupkg to compare with the release before it; empty compares the solution's libraries |
|
projects |
project files to compare instead, one per line | |
target |
only these assemblies, one per line | |
framework |
the target framework to compare | |
fail-on |
major |
fail when the suggested change is at least this, unless the pull request has allow-label; none never fails |
allow-label |
breaking-allowed |
the label that lets a change at fail-on through |
major-label, minor-label, patch-label |
breaking, feature, none |
the release label for each suggested change |
comment, labels |
true |
keep the comment, set the labels |
working-directory |
. |
where the solution is |
token |
github.token |
needs pull-requests: write |
version |
latest | the assembly-differ version to install |
Outputs: suggested-change (None, Patch, Minor or Major), breaking-changes, and report, the path of
api-changes.json.
On a pull request from a fork the token cannot write, so the action writes the job summary and still applies
fail-on, but leaves the comment and labels alone. The deprecated first, second and args inputs still run diff
with two provider specs in the container image, as the action did before.
assembly-differ api writes a library's public API as .api files: C#, one declaration per line, in a tree of
namespaces, types and members, with base types, interfaces, constraints, nullability and default values. public goes
without saying, so only protected members say how visible they are. Commit them,
and a change to the API shows in the pull request as a diff of the file, next to the code that caused it:
dotnet build -c Release && assembly-differ api -o api # write api/MyLib.api for every library in the solution
assembly-differ api --verify api # exit 5 when the build no longer matches themassembly MyLib
using System: ObsoleteAttribute
using interface System: IComparable<>, IDisposable
namespace MyLib.Drawing
interface IShape : IDisposable
double Area { get; }
void Scale(double factor) { … }
sealed class Widget : Shape, IComparable<Widget>
Widget(string name, int size = 1)
required string? Name { get; init; }
--verify says what changed since the committed files, by path and line, who each change breaks (callers,
implementers or inheritors), whether it breaks compiled code, source or both, and the version change it calls for:
[api] MyLib: 2 changes since the committed files, suggested version change: Major
api/MyLib.api:4 MyLib.Drawing › IShape › Resize(double) added void Resize(double factor) [major: breaks implementers, compiled code and source]
api/MyLib.api:9 MyLib.Drawing › Widget › Draw() removed void Draw() [major: breaks callers, compiled code and source]
[api] api does not match the build. Suggested version change: Major
[api] Update the files with: assembly-differ api -o api
A change that only breaks implementers or inheritors of a type nobody outside can implement or derive from is no break;
one on a type marked with an attribute named InternalImplementationOnlyAttribute is listed but does not ask for a
major version. api shows a public API and what changed in it; for everything else in a package, such as its dependencies,
frameworks and files, use dotnet-inspect. api takes a .dll, a
directory or a .nupkg instead of the solution, --split namespace or
--split top-level-namespace for a file per namespace in a large assembly, and -f json to print every part of each
declaration as a field.
Check them on every pull request with the verify-api action. Checkout and the build are your steps, before it:
permissions:
contents: read
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet build -c Release
- uses: nullean/assembly-differ/verify-api@v1
with:
directory: apiIt fails when the files do not match, with the report above in the log. With comment: true (and
pull-requests: write) it also keeps a comment on the pull request with the report and the command that updates the
files. Its other inputs are path, projects, target, framework and split, as for the command, and
working-directory, token and version; its outputs are matches and suggested-change.
Nullean.Snapshots.PublicApi keeps the same .api file
as a snapshot in a test, for repositories that would rather fail a test than a build step:
[Test]
public void Public_api_is_unchanged() => typeof(Widget).Assembly.Should().MatchApiSnapshot();A failure lists each change with the version change it calls for, who it breaks and its line in the verified file.
The same image works anywhere docker run does, such as GitLab CI or a machine without the .NET SDK:
docker run --rm -v "$(pwd)":/workspace ghcr.io/nullean/assembly-differ:edge files /workspace/old /workspace/newIt is native-AOT on a chiseled runtime-deps base, with no shell, running as a non-root user. edge tracks the latest
build of main; latest and the version tags (for example 0.18.1) follow releases.
The .NET SDK's package validation
(EnablePackageValidation, built on ApiCompat) checks that a package is consistent within itself: that its target
frameworks and runtime-specific assemblies are compatible with each other. It can also compare with a baseline version
you set in the project file, and it answers one question: does this break? Keep it for the checks inside one package.
assembly-differ answers the questions around it:
- Which version bump. It suggests
None,Patch,MinororMajor, and treats a new interface, abstract orrequiredmember as breaking, since existing code has to change for it. Package validation reports additions only in strict mode, as errors like removals, and did not flag a newrequiredproperty when we tried it. - Against which release. It finds the release before the version being built on your NuGet.config feeds, and a first release passes, so no baseline version is kept by hand.
- On the pull request. It comments, sets the release-drafter label, and fails on a breaking change until someone adds the allow label.
- For the code that consumes a package.
nuget X 2.0.0andupgradesshow what an update breaks in the code you build, by the API it uses.
The tool ships as one package per platform. Install the root package; the .NET SDK picks the right one.
| Package | What it is | |
|---|---|---|
assembly-differ |
the tool; points the SDK at the package for your platform | |
assembly-differ.<rid> |
the native-AOT build for linux-x64, linux-arm64, win-x64, win-arm64 and osx-arm64 |
|
assembly-differ.skills |
the agent skill for the tool, content only; a development dependency | |
assembly-differ.any |
the framework-dependent fallback (net11.0) for every other platform | |
Nullean.PublicApi |
the library: reads an assembly's public API into a model, and writes and parses .api files (netstandard2.0, net10.0) |
|
Nullean.PublicApi.Diff |
the library: compares two APIs and says who each change breaks and the version change it calls for |
assembly-differ's comparison is a port of JustAssembly, licensed under Apache 2.0.
MIT licensed. Copyright Nullean and contributors.