Loading...
Loading...
Detecting API changes in CI. PublicApiAnalyzers, Verify snapshots, breaking change enforcement.
npx skill4agent add wshaddix/dotnet-skills dotnet-api-surface-validationMicrosoft.CodeAnalysis.AnalyzersMicrosoft.CodeAnalysis.PublicApiAnalyzersEnablePackageValidationEnablePackageValidation<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.*" PrivateAssets="all" />
</ItemGroup>.csprojMyLib/
MyLib.csproj
PublicAPI.Shipped.txt # APIs shipped in released versions
PublicAPI.Unshipped.txt # APIs added since last release#nullable enable#nullable enable| Rule | Severity | Meaning |
|---|---|---|
| RS0016 | Warning | Public API member not declared in API tracking files |
| RS0017 | Warning | Public API member removed but still in tracking files |
| RS0024 | Warning | Public API member has wrong nullable annotation |
| RS0025 | Warning | Public API symbol marked shipped but has changed signature |
| RS0026 | Warning | New public API added without |
| RS0036 | Warning | API file missing |
| RS0037 | Warning | Public API declared but does not exist in source |
publicprotectedPublicAPI.Unshipped.txtpublic#nullable enable
MyLib.Widget
MyLib.Widget.Widget() -> void
MyLib.Widget.Name.get -> string!
MyLib.Widget.Name.set -> void
MyLib.Widget.Calculate(int count) -> decimal
MyLib.Widget.CalculateAsync(int count, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<decimal>!
MyLib.IWidgetFactory
MyLib.IWidgetFactory.Create(string! name) -> MyLib.Widget!
MyLib.WidgetOptions
MyLib.WidgetOptions.WidgetOptions() -> void
MyLib.WidgetOptions.MaxRetries.get -> int
MyLib.WidgetOptions.MaxRetries.set -> void!?Widget.Widget() -> void.get.setPublicAPI.Unshipped.txtPublicAPI.Unshipped.txtPublicAPI.Shipped.txtPublicAPI.Unshipped.txt#nullable enablePublicAPI.Shipped.txtPublicAPI.Unshipped.txtMyLib/
MyLib.csproj
PublicAPI.Shipped.txt # Shared across all TFMs
PublicAPI.Unshipped.txt # Shared across all TFMs
PublicAPI.Shipped.net8.0.txt # net8.0-specific APIs
PublicAPI.Unshipped.net8.0.txt # net8.0-specific APIs
PublicAPI.Shipped.net10.0.txt # net10.0-specific APIs
PublicAPI.Unshipped.net10.0.txt # net10.0-specific APIs.csproj<PropertyGroup>
<RoslynPublicApiPerTfm>true</RoslynPublicApiPerTfm>
</PropertyGroup><!-- In Directory.Build.props or the library .csproj -->
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
</PropertyGroup>using System.Reflection;
using System.Text;
public static class PublicApiExtractor
{
public static string GetPublicApi(Assembly assembly)
{
var sb = new StringBuilder();
var publicTypes = assembly
.GetTypes()
.Where(t => t.IsPublic || t.IsNestedPublic)
.OrderBy(t => t.FullName, StringComparer.Ordinal);
foreach (var type in publicTypes)
{
AppendType(sb, type);
}
return sb.ToString();
}
private static void AppendType(StringBuilder sb, Type type)
{
var kind = type switch
{
{ IsEnum: true } => "enum",
{ IsValueType: true } => "struct",
{ IsInterface: true } => "interface",
{ IsAbstract: true, IsSealed: true } => "static class",
{ IsAbstract: true } => "abstract class",
{ IsSealed: true } => "sealed class",
_ => "class"
};
sb.AppendLine($"{kind} {type.FullName}");
var members = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(m => m.MemberType)
.ThenBy(m => m.Name, StringComparer.Ordinal)
.ThenBy(m => m.ToString(), StringComparer.Ordinal);
foreach (var member in members)
{
sb.AppendLine($" {FormatMember(member)}");
}
sb.AppendLine();
}
private static string FormatMember(MemberInfo member) =>
member switch
{
ConstructorInfo c => $".ctor({FormatParameters(c.GetParameters())})",
MethodInfo m when !m.IsSpecialName =>
$"{m.ReturnType.Name} {m.Name}({FormatParameters(m.GetParameters())})",
PropertyInfo p => $"{p.PropertyType.Name} {p.Name} {{ {GetAccessors(p)} }}",
FieldInfo f => $"{f.FieldType.Name} {f.Name}",
EventInfo e => $"event {e.EventHandlerType?.Name} {e.Name}",
_ => member.ToString() ?? string.Empty
};
private static string FormatParameters(ParameterInfo[] parameters) =>
string.Join(", ", parameters.Select(p => $"{p.ParameterType.Name} {p.Name}"));
private static string GetAccessors(PropertyInfo prop)
{
var parts = new List<string>();
if (prop.GetMethod?.IsPublic == true) parts.Add("get;");
if (prop.SetMethod?.IsPublic == true) parts.Add("set;");
return string.Join(" ", parts);
}
}[UsesVerify]
public class PublicApiSurfaceTests
{
[Fact]
public Task PublicApi_ShouldMatchApprovedSurface()
{
var assembly = typeof(Widget).Assembly;
var publicApi = PublicApiExtractor.GetPublicApi(assembly);
return Verify(publicApi);
}
}.verified.txt.received.txt.received.txt.verified.txtverify accept| Concern | PublicApiAnalyzers | Verify Snapshot |
|---|---|---|
| Detection timing | Build time (in-IDE) | Test time (post-compile) |
| Granularity | Per-member signatures | Assembly-wide surface |
| Nullable annotations | Tracked via | Requires explicit reflection |
| Approval workflow | Edit text files (shipped/unshipped) | Accept snapshot diffs |
| Multi-TFM | Per-TFM files | Per-TFM test targets |
| CI gating | Warnings-as-errors | Test failures |
EnablePackageValidationEnablePackageValidationdotnet pack<PropertyGroup>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.2.0</PackageValidationBaselineVersion>
</PropertyGroup>dotnet pack# GitHub Actions -- gate PRs on API compatibility
name: API Compatibility Check
on:
pull_request:
paths:
- 'src/**'
- '*.props'
- '*.targets'
jobs:
api-compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Pack with API validation
run: dotnet pack --configuration Release --no-build
# EnablePackageValidation runs during pack and fails
# the build if breaking changes are detected# GitHub Actions -- compare assemblies directly
name: API Diff Check
on:
pull_request:
jobs:
api-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install ApiCompat tool
run: dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
- name: Build current branch
run: dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/current
- name: Build baseline (main branch)
run: |
git stash
git checkout origin/main -- src/MyLib/
dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/baseline
git checkout - -- src/MyLib/
git stash pop || true
- name: Compare APIs
run: |
apicompat --left-assembly artifacts/baseline/MyLib.dll \
--right-assembly artifacts/current/MyLib.dll - name: Check for API changes
id: api-check
continue-on-error: true
run: |
apicompat --left-assembly artifacts/baseline/MyLib.dll \
--right-assembly artifacts/current/MyLib.dll 2>&1 | tee api-diff.txt
echo "has_changes=$([[ -s api-diff.txt ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
- name: Label PR with API changes
if: steps.api-check.outputs.has_changes == 'true'
run: gh pr edit "${{ github.event.pull_request.number }}" --add-label "api-change"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}dotnet pack /p:GenerateCompatibilitySuppressionFile=trueCompatibilitySuppressions.xml<ItemGroup>
<ApiCompatSuppressionFile Include="CompatibilitySuppressions.xml" />
</ItemGroup>ApiCompatSuppressionFile<?xml version="1.0" encoding="utf-8"?>
<Suppressions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:MyLib.Widget.Calculate</Target>
<Left>lib/net8.0/MyLib.dll</Left>
<Right>lib/net8.0/MyLib.dll</Right>
</Suppression>
</Suppressions> - name: Build with API tracking enforcement
run: dotnet build -c Release /p:TreatWarningsAsErrors=true /warnaserror:RS0016,RS0017,RS0036,RS0037
- name: Verify PublicAPI files are committed
run: |
if git diff --name-only | grep -q 'PublicAPI'; then
echo "::error::PublicAPI tracking files have uncommitted changes"
git diff -- '**/PublicAPI.*.txt'
exit 1
fi<!-- Directory.Build.props -- applied to all library projects -->
<Project>
<PropertyGroup Condition="'$(IsPackable)' == 'true'">
<EnablePackageValidation>true</EnablePackageValidation>
<WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
</PropertyGroup>
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers"
Version="3.3.*" PrivateAssets="all" />
</ItemGroup>
</Project>PublicAPI.Shipped.txtPublicAPI.Unshipped.txt#nullable enableApiCompatSuppressionFile<ApiCompatSuppressionFile Include="..." />PublicAPI.Unshipped.txtPublicAPI.Shipped.txt<NoWarn>publicInternalsVisibleTo[EditorBrowsable(EditorBrowsableState.Never)]GenerateCompatibilitySuppressionFile=trueMicrosoft.CodeAnalysis.PublicApiAnalyzersEnablePackageValidationdotnet packMicrosoft.DotNet.ApiCompat.Tooldotnet pack