ExtractApiChanges
What happens if the release notes say “ErrorCodeFactory class was added” but the class does not actually exist in the code? API accuracy determines the trustworthiness of release notes. ExtractApiChanges.cs fundamentally solves this problem. It extracts Public APIs directly from compiled DLLs, not source code, generating API information that is 100% consistent with the actual build output. The Uber file, which combines all assembly APIs into one, serves as the Single Source of Truth when Claude writes the release notes.
File Location and Usage
Section titled “File Location and Usage”.release-notes/scripts/ExtractApiChanges.cscd .release-notes/scriptsdotnet ExtractApiChanges.csNo arguments are needed. It automatically discovers the Git root and projects based on the current directory.
Script Structure
Section titled “Script Structure”Package references and CLI definition follow the same pattern as AnalyzeAllComponents.cs, but this script operates without arguments.
#!/usr/bin/env dotnet
#:package System.CommandLine@2.0.1#:package Spectre.Console@0.54.0
using System;using System.Collections.Generic;using System.CommandLine;using System.Diagnostics;using System.IO;using System.Linq;using System.Text;using System.Threading.Tasks;using Spectre.Console;var rootCommand = new RootCommand("Extract API changes by building current branch");
rootCommand.SetAction(async (parseResult, cancellationToken) =>{ await ExtractApiChangesAsync(); return 0;});
return await rootCommand.Parse(args).InvokeAsync();What Happens When the Script Runs
Section titled “What Happens When the Script Runs”The script goes through five steps to collect API data. The result of each step becomes the input for the next step in a pipeline structure.
Step 1: Check ApiGenerator
Section titled “Step 1: Check ApiGenerator”First, check whether ApiGenerator.cs, the core tool for API extraction, exists. Without this file, subsequent steps cannot proceed, so the script exits immediately.
AnsiConsole.MarkupLine("[bold]Step 1[/] [dim]Locating ApiGenerator...[/]");var apiGeneratorPath = Path.Combine(toolsDir, "ApiGenerator.cs");
if (!File.Exists(apiGeneratorPath)){ AnsiConsole.MarkupLine("[red]Error:[/] ApiGenerator.cs not found"); Environment.Exit(1);}AnsiConsole.MarkupLine($" [green]Found[/] [dim]{apiGeneratorPath}[/]");Step 2: Discover Projects
Section titled “Step 2: Discover Projects”Find .csproj files starting with Functorium in the Src directory, excluding test projects.
AnsiConsole.MarkupLine("[bold]Step 2[/] [dim]Finding Functorium projects...[/]");var srcDir = Path.Combine(gitRoot, "Src");
var projectFiles = Directory.GetFiles(srcDir, "*.csproj", SearchOption.AllDirectories) .Where(p => !p.Contains(".Tests.") && Path.GetFileName(p).StartsWith("Functorium")) .OrderByDescending(p => p) .ToList();
AnsiConsole.MarkupLine($" [green]Found[/] [white]{projectFiles.Count}[/] projects");Step 3: Build Projects and Generate APIs
Section titled “Step 3: Build Projects and Generate APIs”This step is the core of the script. Each project is built with dotnet publish, and then the generated DLL is passed to ApiGenerator.cs to extract the Public API.
foreach (var projectFile in projectFiles){ var assemblyName = Path.GetFileNameWithoutExtension(projectFile); var publishDir = Path.Combine(projectDir, "bin", "publish");
await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) .StartAsync($"Publishing [cyan]{assemblyName}[/]...", async ctx => { // 1. Build project var publishResult = await RunProcessAsync( "dotnet", $"publish \"{projectFile}\" -c Release -o \"{publishDir}\"" );
if (publishResult.ExitCode != 0) { AnsiConsole.MarkupLine($" [yellow]WARN[/] {assemblyName} - Publish failed"); return; }
// 2. Extract API with ApiGenerator var dllPath = Path.Combine(publishDir, $"{assemblyName}.dll"); var apiResult = await RunProcessAsync( "dotnet", $"\"{apiGeneratorPath}\" \"{dllPath}\" -" );
// 3. Generate API file if (apiResult.ExitCode == 0 && !string.IsNullOrWhiteSpace(apiResult.Output)) { var content = new StringBuilder(); content.AppendLine("// <auto-generated>"); content.AppendLine($"// Assembly: {assemblyName}"); content.AppendLine($"// Generated at: {DateTime.Now}"); content.AppendLine("// </auto-generated>"); content.Append(apiResult.Output);
await File.WriteAllTextAsync(outputFile, content.ToString()); generatedApiFiles.Add(outputFile); } });}Step 4: Generate Uber File
Section titled “Step 4: Generate Uber File”Combine all assembly APIs into a single file (all-api-changes.txt). The reason for combining into a single file is to simplify verification. When writing release notes in Phase 4, checking whether code example APIs are accurate requires looking at only this one file.
AnsiConsole.MarkupLine("[bold]Step 4[/] [dim]Creating Uber API file...[/]");
var uberContent = new StringBuilder();uberContent.AppendLine("// All API Changes - Uber File");uberContent.AppendLine($"// Generated: {DateTime.Now}");uberContent.AppendLine();
foreach (var apiFile in generatedApiFiles){ var assemblyName = Path.GetFileNameWithoutExtension(apiFile); uberContent.AppendLine($"// ═══════════════════════════════════════════"); uberContent.AppendLine($"// Assembly: {assemblyName}"); uberContent.AppendLine($"// ═══════════════════════════════════════════"); uberContent.AppendLine();
var content = await File.ReadAllTextAsync(apiFile); uberContent.AppendLine(content); uberContent.AppendLine();}
var uberFilePath = Path.Combine(apiChangesDir, "all-api-changes.txt");await File.WriteAllTextAsync(uberFilePath, uberContent.ToString());Step 5: Generate Git Diff
Section titled “Step 5: Generate Git Diff”Finally, generate the Git diff of .api/*.cs files. This diff shows how the API has changed compared to the previous release and is used for automatic Breaking Change detection.
AnsiConsole.MarkupLine("[bold]Step 5[/] [dim]Generating API diff...[/]");
var diffResult = await RunProcessAsync( "git", $"diff HEAD -- \"Src/*/.api/*.cs\"");
var diffFilePath = Path.Combine(apiChangesDir, "api-changes-diff.txt");await File.WriteAllTextAsync(diffFilePath, diffResult.Output);
if (string.IsNullOrWhiteSpace(diffResult.Output)){ AnsiConsole.MarkupLine(" [dim]No API changes detected[/]");}else{ AnsiConsole.MarkupLine($" [green]✓[/] Diff saved to api-changes-diff.txt");}Output File Structure
Section titled “Output File Structure”The files generated by the script are located in two places.
.analysis-output/api-changes-build-current/├── all-api-changes.txt # Uber file (all APIs)├── api-changes-summary.md # API summary└── api-changes-diff.txt # Git Diff
Src/├── Functorium/.api/│ └── Functorium.cs # Functorium Public API└── Functorium.Testing/.api/ └── Functorium.Testing.cs # Testing Public APIUber File Example
Section titled “Uber File Example”all-api-changes.txt contains the Public APIs of all assemblies in a single file.
// All API Changes - Uber File// Generated: 2025-12-19 10:30:00
// ═══════════════════════════════════════════// Assembly: Functorium// ═══════════════════════════════════════════
namespace Functorium.Abstractions.Errors{ public static class ErrorCodeFactory { public static LanguageExt.Common.Error Create(string errorCode, string errorCurrentValue, string errorMessage); public static LanguageExt.Common.Error Create<T>(string errorCode, T errorCurrentValue, string errorMessage) where T : notnull; public static LanguageExt.Common.Error CreateFromException(string errorCode, System.Exception exception); }}
// ═══════════════════════════════════════════// Assembly: Functorium.Testing// ═══════════════════════════════════════════
namespace Functorium.Testing.Arrangements.Hosting{ public class HostTestFixture<TProgram> : System.IAsyncDisposable where TProgram : class { public System.Net.Http.HttpClient Client { get; } public System.IServiceProvider Services { get; } }}API Diff Example
Section titled “API Diff Example”In api-changes-diff.txt, you can see added APIs (+) and deleted APIs (-) at a glance.
diff --git a/Src/Functorium/.api/Functorium.cs b/Src/Functorium/.api/Functorium.csindex abc1234..def5678 100644--- a/Src/Functorium/.api/Functorium.cs+++ b/Src/Functorium/.api/Functorium.cs@@ -10,6 +10,7 @@ namespace Functorium.Abstractions.Errors public static class ErrorCodeFactory { public static Error Create(string errorCode, string errorCurrentValue, string errorMessage);+ public static Error CreateFromException(string errorCode, Exception exception); } }Key Functions
Section titled “Key Functions”RunProcessAsync
Section titled “RunProcessAsync”Executes an external process and returns the output and exit code. Git commands, dotnet publish, and ApiGenerator calls all go through this function.
static async Task<ProcessResult> RunProcessAsync(string command, string arguments, bool quiet = false){ var process = new Process { StartInfo = new ProcessStartInfo { FileName = command, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
var output = new StringBuilder(); var error = new StringBuilder();
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync();
return new ProcessResult { ExitCode = process.ExitCode, Output = output.ToString(), Error = error.ToString() };}GetCurrentBranchAsync
Section titled “GetCurrentBranchAsync”Gets the name of the currently checked-out branch.
static async Task<string> GetCurrentBranchAsync(){ var result = await RunProcessAsync("git", "rev-parse --abbrev-ref HEAD", quiet: true); return result.Output.Trim();}Console Output
Section titled “Console Output”Running the script produces output like the following.
━━━━━━━━━━━━ Extracting API Changes ━━━━━━━━━━━━
╭──────────┬─────────────────────────────╮│ Property │ Value │├──────────┼─────────────────────────────┤│ Branch │ main ││ Output │ .analysis-output/... ││ Timestamp│ 2025-12-19 10:30:00 │╰──────────┴─────────────────────────────╯
Step 1 Locating ApiGenerator... Found .release-notes/scripts/ApiGenerator.cs
Step 2 Finding Functorium projects... Found 2 projects
Step 3 Publishing projects and generating API files... ✓ Functorium ✓ Functorium.Testing
Step 4 Creating Uber API file... ✓ all-api-changes.txt (2 assemblies)
Step 5 Generating API diff... ✓ Diff saved to api-changes-diff.txt
━━━━━━━━━━━━ API Extraction Complete ━━━━━━━━━━━━ApiGenerator.cs Integration
Section titled “ApiGenerator.cs Integration”ExtractApiChanges.cs internally calls ApiGenerator.cs as a subprocess. It generates a DLL with dotnet publish, passes the DLL path to ApiGenerator.cs, and the PublicApiGenerator library returns the Public API text to standard output.
ExtractApiChanges.cs │ ├─▶ dotnet publish (project build) │ │ │ └─▶ bin/publish/Assembly.dll │ └─▶ dotnet ApiGenerator.cs <dll-path> │ └─▶ Public API text outputError Handling
Section titled “Error Handling”Errors like build failures or DLL not found skip the affected component and continue processing the rest. This is designed so that a single project failure does not halt the entire process.
if (publishResult.ExitCode != 0){ AnsiConsole.MarkupLine($" [yellow]WARN[/] [dim]{assemblyName}[/] - Publish failed, skipping"); return;}var dllPath = Path.Combine(publishDir, $"{assemblyName}.dll");if (!File.Exists(dllPath)){ AnsiConsole.MarkupLine($" [yellow]WARN[/] [dim]{assemblyName}[/] - DLL not found"); return;}While ExtractApiChanges.cs records “APIs that actually exist in the current code” through the Uber file, the actual work of extracting Public APIs from DLLs is handled by ApiGenerator.cs. The next section examines how ApiGenerator.cs loads assemblies and extracts APIs using the PublicApiGenerator library.
Q1: Why does ExtractApiChanges.cs extract APIs from DLLs rather than source code?
Section titled “Q1: Why does ExtractApiChanges.cs extract APIs from DLLs rather than source code?”A: Parsing source code may extract APIs different from the actual build result due to #if preprocessor directives, partial class, Source Generators, etc. Compiled DLLs are the final output reflecting all these processes, so they guarantee API information that is 100% consistent with the Public API end users will encounter.
Q2: Why combine all assemblies into a single Uber file (all-api-changes.txt) instead of separating by assembly?
Section titled “Q2: Why combine all assemblies into a single Uber file (all-api-changes.txt) instead of separating by assembly?”A: When writing release notes in Phase 4 and verifying in Phase 5, cross-referencing multiple files can lead to omissions. Combining into a single file allows checking all assembly APIs with a single search, making the verification process simple and accurate.
Q3: Why does the rest continue even if one project build fails?
Section titled “Q3: Why does the rest continue even if one project build fails?”A: The release notes only need to include information for successfully built components. When a build fails, the component is skipped (with a WARN log output) and the rest continues processing using a Partial Failure strategy, designed so that a single problem does not halt the entire workflow.
Q4: When does api-changes-diff.txt become an empty file?
Section titled “Q4: When does api-changes-diff.txt become an empty file?”A: It is empty for first deployments, or when there are no previous version API files committed in the .api folder. In this case, Breaking Change detection relies solely on commit message patterns (feat!:, BREAKING CHANGE). Committing .api folder files with each release ensures accurate diffs for the next release.