Package Validation and API Compatibility

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

A binary breaking change compiles fine in the library and fails at run time in someone else’s application. The SDK can catch most of them at pack time: package validation (since the .NET 6 SDK) compares the new package with itself across target frameworks and runtimes, and with a previously published baseline version, using the APICompat engine. This page turns it on for Ledgerline, breaks the API on purpose, and reads the errors, with .NET SDK 10.0.402. Which changes are breaking is explained on Designing a Library for NuGet.

Turning it on

<PropertyGroup>
  <EnablePackageValidation>true</EnablePackageValidation>
  <!-- compare with the last published release -->
  <PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion>
</PropertyGroup>

The validation runs as part of Pack. Three validators:

  • Compatible framework in package — code compiled against one framework of the package must run against every other compatible one (netstandard2.0 vs net8.0 vs net10.0 in Ledgerline). Always on.

  • Compatible runtime — RID-specific implementation assemblies (runtimes/<rid>/lib/) must match each other and the compile-time assembly. Always on.

  • Baseline version — the package must be compatible with the baseline package, which restore downloads from the configured feeds like any other package. On only when PackageValidationBaselineVersion is set.

A removed member: CP0002

Ledgerline 1.0.0 is in the local feed. Version 1.1.0 removes LedgerReader.ReadFile(string):

dotnet pack src/Ledgerline -o artifacts/pv     # Version 1.1.0, baseline 1.0.0
error : API compatibility errors between 'lib/net10.0/Ledgerline.dll' (.../nuget-packages/ledgerline/1.0.0/ledgerline.1.0.0.nupkg) and 'lib/net10.0/Ledgerline.dll' (artifacts/pv/Ledgerline.1.1.0.nupkg):
error CP0002: Member 'System.Collections.Generic.IReadOnlyList<Ledgerline.JournalLine> Ledgerline.LedgerReader.ReadFile(string)' exists on [Baseline] lib/net10.0/Ledgerline.dll but not on lib/net10.0/Ledgerline.dll
error CP0002: Member '...ReadFile(string)' exists on [Baseline] lib/net8.0/Ledgerline.dll but not on lib/net8.0/Ledgerline.dll
error CP0002: Member '...ReadFile(string)' exists on [Baseline] lib/netstandard2.0/Ledgerline.dll but not on lib/netstandard2.0/Ledgerline.dll
error : API breaking changes found. If those are intentional, the APICompat suppression file can be updated by rebuilding with '/p:ApiCompatGenerateSuppressionFile=true'

(The messages from Microsoft.NET.ApiCompat.ValidatePackage.targets are shortened.) One error per target framework. The build fails — but the .nupkg had already been written to artifacts/pv (25,383 bytes), so a script must check the exit code, not the presence of the file.

An API that differs between frameworks: CP0001

A public type compiled only for netstandard2.0 (#if !NET8_0_OR_GREATER) breaks the "compatible frameworks" rule — a net8.0 application compiled against the netstandard2.0 assembly by another library would fail to find it:

error CP0001: Type 'Ledgerline.LegacyHelpers' exists on lib/netstandard2.0/Ledgerline.dll but not on lib/net8.0/Ledgerline.dll

The opposite (an API only on the newer framework) is allowed by default and reported in strict mode.

Strict mode and dropped frameworks

By default only removals are errors: adding API is compatible. Strict mode requires the two sides to be identical — useful for a servicing (patch) release that must not add API. A 1.1.0 with a new LedgerStats class, packed with -p:EnableStrictModeForBaselineValidation=true:

error CP0001: Type 'Ledgerline.LedgerStats' exists on lib/net10.0/Ledgerline.dll but not on [Baseline] lib/net10.0/Ledgerline.dll
error CP0003: lib/net10.0/Ledgerline.dll assembly version '1.1.0.0' should be equal to [Baseline] lib/net10.0/Ledgerline.dll version '1.0.0.0'.

(and the same for net8.0 and netstandard2.0). Strict mode also requires the same assembly version — so it fits patch releases with a fixed AssemblyVersion, not a minor release. Without strict mode the same package validated cleanly. The three switches: EnableStrictModeForBaselineValidation, EnableStrictModeForCompatibleFrameworksInPackage and EnableStrictModeForCompatibleTfms.

Dropping a target framework is a breaking change too:

error PKV006: Target framework .NETStandard,Version=v2.0 is no longer supported in the latest version.

Other properties: PackageValidationBaselineName (a baseline with a different ID, after a rename), PackageValidationBaselineFrameworkToIgnore items (frameworks of the baseline deliberately dropped) and PackageValidationReferencePath items (reference assemblies per framework, when the validator can’t resolve dependencies).

Suppressing an intentional break

When a break is intended (a major version), record it instead of disabling validation:

dotnet pack src/Ledgerline -o artifacts/pv -p:ApiCompatGenerateSuppressionFile=true
  Successfully wrote compatibility suppressions to 'src/Ledgerline/CompatibilitySuppressions.xml'.
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <Suppression>
    <DiagnosticId>CP0002</DiagnosticId>
    <Target>M:Ledgerline.LedgerReader.ReadFile(System.String)</Target>
    <Left>lib/net10.0/Ledgerline.dll</Left>
    <Right>lib/net10.0/Ledgerline.dll</Right>
    <IsBaselineSuppression>true</IsBaselineSuppression>
  </Suppression>
  <!-- the same for net8.0 and netstandard2.0 -->
</Suppressions>

The next pack read the file automatically and reported no error. Commit it, so the break is reviewed like code. The error message names ApiCompatGenerateSuppressionFile; the documentation names GenerateCompatibilitySuppressionFile — both wrote the file on 10.0.402. After the release, move the baseline to the new version and delete the suppressions.

Diagnostic IDs

ID Meaning

CP0001

A public type is missing on one side.

CP0002

A public member is missing on one side.

CP0003

Part of the assembly identity differs (name, public key token, culture, version).

CP0004

No matching assembly on one side.

CP0005, CP0006

An abstract member added to an unsealed type; an interface member added without a default implementation.

CP0007, CP0008

A base type or base interface removed.

CP0009

A type became sealed.

CP0010, CP0011

An enum’s underlying type or a member’s value changed.

CP0012, CP0013

virtual/abstract removed; virtual added.

CP0014 — CP0016

Attributes removed, changed or added (only with attribute checks on).

CP0017

A parameter was renamed (a source break for named arguments).

CP0018

An interface member became sealed.

CP0019, CP0020

Visibility reduced or expanded.

CP1001 — CP1003

An assembly or reference assembly could not be found.

PKV001 — PKV005

A compile-time or run-time asset is missing for a compatible framework or RID.

PKV006, PKV007

A target framework, or framework and RID pair, was dropped.

The list currently ends at CP0020; there is no CP0021. A strong-named net8.0 build next to unsigned builds of the other frameworks gave CP0003: lib/net8.0/Ledgerline.dll assembly public key token '719c79c5e8e6e9af' does not match with lib/net10.0/Ledgerline.dll 'null'.

ApiCompat outside the build

Microsoft.DotNet.ApiCompat.Tool (since the .NET 8 SDK; 10.0.402 here) compares two assemblies, or two packages, from the command line — for example a library that isn’t a NuGet package:

dotnet tool install Microsoft.DotNet.ApiCompat.Tool --version 10.0.402 --tool-path ./apitool
./apitool/apicompat --left old/lib/net8.0/Ledgerline.dll --right src/Ledgerline/bin/Release/net8.0/Ledgerline.dll
API compatibility errors between 'old/lib/net8.0/Ledgerline.dll' (left) and '.../bin/Release/net8.0/Ledgerline.dll' (right):
CP0002: Member 'System.Collections.Generic.IReadOnlyList<Ledgerline.JournalLine> Ledgerline.LedgerReader.ReadFile(string)' exists on old/lib/net8.0/Ledgerline.dll but not on .../bin/Release/net8.0/Ledgerline.dll
API breaking changes found. If those are intentional, the APICompat suppression file can be updated by specifying the '--generate-suppression-file' parameter.

It exited with code 1. apicompat package <nupkg> --baseline-package <nupkg> runs the package validators the same way, and inside a build ApiCompatValidateAssemblies=true (with a reference to the separate Microsoft.DotNet.ApiCompat.Task package) compares an assembly with a contract assembly.

The documentation’s samples are old: the baseline validator page shows a net6.0 project and output from SDK 6.0.413, the framework validator pages netstandard2.0;net6.0 with output from a 6.0.100-rc.1 SDK. The messages above are what 10.0.402 prints; the targets file named in them is now Microsoft.NET.ApiCompat.ValidatePackage.targets instead of Microsoft.NET.Compatibility.Common.targets.

Common mistakes

  • Enabling validation without a baseline — only the cross-framework checks run; removals between releases pass.

  • A baseline the feed can’t provide — restore fails; the baseline must be on a configured source.

  • Suppressing instead of fixing — a suppression file is a list of breaks you ship.

  • Strict mode on a minor release — it rejects every added API and the new assembly version.

  • Relying on the .nupkg existing — it is written even when validation fails.