Skip to content

About

Compare and Diff assemblies from different sources. Useful for determining what changes are introduced across versions, and if any are breaking.

Topics

Resources

Contributing

Security policy

Stars

72 stars

Watchers

2 watching

Forks

Latest commit

 

History

127 Commits

Folders and files

Repository files navigation

nullean

assembly-differ

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.

NuGet CI

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&lt;TConsoleOut&gt;" DiffType="Modified">
      <Method Name="WaitForCompletion(Nullable&lt;TimeSpan&gt;, CancellationToken)" DiffType="New" />
      <Method Name="WaitForCompletion(Nullable&lt;TimeSpan&gt;)" 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: Major

Install

dotnet tool install --global assembly-differ

On 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.

For coding agents

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 libraries

api-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.skills

Quick start

In 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-differ

It 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 major

The 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.0

In 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 MyLib

On a pull request

pull-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.json

It 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.

Provider specs

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.

GitHub Action

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@v1

Without 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.

Public API files

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 them
assembly 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.

An enum default is written by name, Formatting formatting = Formatting.Indented, which needs the assembly that declares the enum. A project's come from its restore, obj/project.assets.json, and the framework's from the installed runtime. A .nupkg finds its dependencies, and theirs, in the packages folder restore fills (~/.nuget/packages, or NUGET_PACKAGES), at the version restore would pick; nothing is downloaded. A .dll read on its own finds only what sits next to it: pass the folders that hold its dependencies with -r. An enum that is not found is written as a cast, (Formatting)1.

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: api

It 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.

In a test

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.

Container

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/new

It 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.

Next to package validation

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, Minor or Major, and treats a new interface, abstract or required member 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 new required property 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.0 and upgrades show what an update breaks in the code you build, by the API it uses.

Packages

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 NuGet
assembly-differ.<rid> the native-AOT build for linux-x64, linux-arm64, win-x64, win-arm64 and osx-arm64 NuGet
assembly-differ.skills the agent skill for the tool, content only; a development dependency NuGet
assembly-differ.any the framework-dependent fallback (net11.0) for every other platform NuGet
Nullean.PublicApi the library: reads an assembly's public API into a model, and writes and parses .api files (netstandard2.0, net10.0) NuGet
Nullean.PublicApi.Diff the library: compares two APIs and says who each change breaks and the version change it calls for NuGet

Learn more

assembly-differ's comparison is a port of JustAssembly, licensed under Apache 2.0.

MIT licensed. Copyright Nullean and contributors.

About

Compare and Diff assemblies from different sources. Useful for determining what changes are introduced across versions, and if any are breaking.

Topics

Resources

Contributing

Security policy

Stars

72 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages