Story
As a Fallout maintainer,
I want an automated test that captures our public API surface and fails when it changes unexpectedly,
so that we catch unintended breaking changes before they ship — against our own last-published version (semver discipline) and against the original NUKE surface (migration fidelity + migrator-tool coverage signal).
Status: Draft for contributor discussion (esp. @dennisdoomen). No code cut yet.
Labels: enhancement, RFC, target/2026.
Background / prior art
Inspired by Dennis Doomen's Mockly.ApiVerificationTests. His ApiApproval test:
- Loads the built
Mockly.dll and calls assembly.GeneratePublicApi() from PublicApiGenerator to produce a canonical C# text representation of the public surface.
- Drives an xUnit
[Theory] off the TargetFrameworks parsed from the csproj, so each TFM gets its own approved snapshot.
- Uses Verify to diff the generated API against a committed
ApprovedApi/<framework>.verified.txt. The snapshot in git is the contract.
Well-trodden approach (Andrew Lock, Preventing breaking changes in public APIs with PublicApiGenerator; used by graphql-dotnet, OpenAPI.NET, FluentAssertions).
We already have the building blocks: Verify.Xunit + Verify.DiffPlex are central packages used throughout tests/. Only PublicApiGenerator is new (and, eventually, JetBrains.Annotations).
The two-baselines model (core concept)
There are two distinct baselines doing two distinct jobs — conflating them is the trap:
- Committed snapshot (
.verified.txt, tracks main HEAD). Dennis's model — re-approved each PR. Answers "did this PR move the surface? reviewer, be aware." A change-awareness net. Because it moves forward on every merge, it cannot tell you whether you broke a consumer.
- Published-package baseline (pinned, only moves when we ship). Answers "does the cumulative unreleased delta break what people actually have?" This is the semver authority — and it is why the baseline must be the latest published version, not
main HEAD. Breaking something added to main last week but never published breaks nobody; it is all one unreleased window. The contract consumers depend on is the last thing we shipped. Comparing against main HEAD would flag zero-impact churn and miss cumulative breaks that matter.
Decision: current surface = built DLL (hermetic, matches the branch); breaking-change baseline = latest published package (fetched live, pinned). Committed snapshot = day-to-day net; published-package diff = the authoritative "this needs a major bump" signal that drives the breaking-change label (see D6).
Goals
- A
tests/Fallout.ApiVerificationTests project that snapshots the public API of our publishable packages and surfaces unreviewed API changes in CI.
- Cover the multi-targeted reality: publishable projects span
netstandard2.0 → net10.0; one snapshot per (package × TFM).
- Authoritative breaking-change detection against the latest published version (per the two-baselines model).
- A permanent check against the upstream NUKE surface that doubles as a coverage signal for the
Fallout.Migrate tool (see D3).
Non-goals
- A public plugin SDK contract (milestone #7, later major).
- Runtime/behavioral compatibility — this is the shape of the API, not its behavior.
- Replacing human judgement: a diff flags change; a maintainer decides whether it is an acceptable break per channel/versioning policy.
- Publishing official public-API documentation.
PUBLIC_API.md is not that (see D5); real API docs must be a conscious, [PublicAPI]-coupled decision (see D4).
Resolved decisions
D1/D2 — Current = built DLL; baseline = latest published GA (nuget.org) ✅
Per the two-baselines model. The authoritative breaking-change baseline is the latest GA on nuget.org (the production contract; breaking changes batch to the yearly major, so GA defines stability). Optional secondary: also diff against the latest -preview (GitHub Packages) for earlier awareness, non-authoritative.
D3 — NUKE comparison is permanent and feeds migrator coverage ✅
Kept for the multi-year transition window (≥ ~1 year, likely longer). Namespaces differ (Nuke.* → Fallout.*); we normalize Fallout. ↔ Nuke. prefixes before diffing. New framing: every break detected between NUKE and Fallout that is not absorbed by a transition shim (src/Shims/) or a Fallout.Migrate rule is a migrator backlog item. The test becomes a standing indicator that the migrator has a gap — not a throwaway rebrand gate.
D5 — Retire build/Build.PublicApi.cs (eventually) ✅
It reflects FalloutBuild into a human-readable PUBLIC_API.md — one of Matthias's one-off oddities. Leave it for now, but it is slated for retirement. We will not treat "it happens to be public" as an official API. If we want published API documentation, it must be a deliberate, [PublicAPI]-coupled artifact — not auto-scraped from accidental visibility. This new verification project is not built on top of Build.PublicApi.cs.
D6 — Label-gated breaking changes ✅
No hard CI fail that blocks merge on an intentional break. When the published-package diff shows a breaking delta, the PR gets the breaking-change label → signals a required major bump → we decide when and where to merge (it accumulates toward the yearly major per ADR-0004/AGENTS.md rule #1). The day-to-day committed-snapshot test still fails-and-prompts-re-approval as a normal awareness gate.
Open decisions (need consensus)
D4 — [PublicAPI] annotation: destination vs. phase 1
- Philosophical destination (Chris): the intended public surface should be an explicit
[PublicAPI] decision, not "oh look, it's public." This is the right end state and aligns with D5.
- Constraint: PublicApiGenerator has no native
[PublicAPI] filter — we reflect for the attribute and feed survivors into its IncludeTypes option. We use zero JetBrains.Annotations today; annotating NUKE's large incidentally-public surface is a repo-wide effort and a design commitment.
- Proposed phasing: Phase 1 snapshots all-public (accept the noise — get the safety net now), explicitly labelled a temporary net, not the endorsed definition of "our API." Phase 2 introduces
[PublicAPI] + an annotated-only snapshot as the intended-surface contract, dovetailing with the plugin-architecture work. Open: do we accept the phase-1 noise, or block on annotating first?
D7 — CI network dependency for the published-package baseline
Fetching the latest published package at test time adds a network dependency on CI (and a "feed unreachable" failure mode). Options: (a) fetch live each run; (b) cache/pin the baseline package version and bump it deliberately on each release; (c) generate the baseline snapshot at release time and commit it. Leaning (b) — pinned, deliberate bump — to keep CI hermetic and the baseline auditable in git.
Proposed approach (post-consensus)
- New
tests/Fallout.ApiVerificationTests, registered in fallout.slnx, inheriting auto-injected test deps via Directory.Build.props. Add PublicApiGenerator to Directory.Packages.props.
[Theory] over (package, framework) pairs; framework list parsed from each csproj's TargetFrameworks (Dennis's pattern, generalized to N packages).
GeneratePublicApi() per assembly → Verify against committed ApprovedApi/<Package>.<framework>.verified.txt (awareness net).
- Separate breaking-change check: built surface vs. latest published GA package, normalized + scrubbed; breaking delta →
breaking-change label workflow.
- NUKE-comparison check with
Nuke.↔Fallout. normalization; uncovered breaks → migrator backlog.
- Scrub volatile lines (
FrameworkDisplayName, calver -preview version/height stamps).
Acceptance criteria (draft — finalize after D4/D7)
- Why
net8.0-only for the test project while the SUT multi-targets — was multi-TFM snapshotting on the SUT enough, or did you hit pain generating cross-TFM?
- Did you consider
[PublicAPI]/annotation-based filtering and reject it, or was Mockly's surface small enough that all-public was fine?
- Any gotchas with
Assembly.LoadFile + GeneratePublicApi across TFMs / on CI runners?
- Have you ever diffed the built surface against a published NuGet package (vs. a committed snapshot), and if so how did you handle the network/pinning question (our D7)?
References
Story
As a Fallout maintainer,
I want an automated test that captures our public API surface and fails when it changes unexpectedly,
so that we catch unintended breaking changes before they ship — against our own last-published version (semver discipline) and against the original NUKE surface (migration fidelity + migrator-tool coverage signal).
Background / prior art
Inspired by Dennis Doomen's
Mockly.ApiVerificationTests. HisApiApprovaltest:Mockly.dlland callsassembly.GeneratePublicApi()from PublicApiGenerator to produce a canonical C# text representation of the public surface.[Theory]off theTargetFrameworksparsed from the csproj, so each TFM gets its own approved snapshot.ApprovedApi/<framework>.verified.txt. The snapshot in git is the contract.Well-trodden approach (Andrew Lock, Preventing breaking changes in public APIs with PublicApiGenerator; used by graphql-dotnet, OpenAPI.NET, FluentAssertions).
We already have the building blocks:
Verify.Xunit+Verify.DiffPlexare central packages used throughouttests/. OnlyPublicApiGeneratoris new (and, eventually,JetBrains.Annotations).The two-baselines model (core concept)
There are two distinct baselines doing two distinct jobs — conflating them is the trap:
.verified.txt, tracksmainHEAD). Dennis's model — re-approved each PR. Answers "did this PR move the surface? reviewer, be aware." A change-awareness net. Because it moves forward on every merge, it cannot tell you whether you broke a consumer.mainHEAD. Breaking something added tomainlast week but never published breaks nobody; it is all one unreleased window. The contract consumers depend on is the last thing we shipped. Comparing againstmainHEAD would flag zero-impact churn and miss cumulative breaks that matter.Decision: current surface = built DLL (hermetic, matches the branch); breaking-change baseline = latest published package (fetched live, pinned). Committed snapshot = day-to-day net; published-package diff = the authoritative "this needs a major bump" signal that drives the breaking-change label (see D6).
Goals
tests/Fallout.ApiVerificationTestsproject that snapshots the public API of our publishable packages and surfaces unreviewed API changes in CI.netstandard2.0→net10.0; one snapshot per (package × TFM).Fallout.Migratetool (see D3).Non-goals
PUBLIC_API.mdis not that (see D5); real API docs must be a conscious,[PublicAPI]-coupled decision (see D4).Resolved decisions
D1/D2 — Current = built DLL; baseline = latest published GA (nuget.org) ✅
Per the two-baselines model. The authoritative breaking-change baseline is the latest GA on nuget.org (the production contract; breaking changes batch to the yearly major, so GA defines stability). Optional secondary: also diff against the latest
-preview(GitHub Packages) for earlier awareness, non-authoritative.D3 — NUKE comparison is permanent and feeds migrator coverage ✅
Kept for the multi-year transition window (≥ ~1 year, likely longer). Namespaces differ (
Nuke.*→Fallout.*); we normalizeFallout.↔Nuke.prefixes before diffing. New framing: every break detected between NUKE and Fallout that is not absorbed by a transition shim (src/Shims/) or aFallout.Migraterule is a migrator backlog item. The test becomes a standing indicator that the migrator has a gap — not a throwaway rebrand gate.D5 — Retire
build/Build.PublicApi.cs(eventually) ✅It reflects
FalloutBuildinto a human-readablePUBLIC_API.md— one of Matthias's one-off oddities. Leave it for now, but it is slated for retirement. We will not treat "it happens to be public" as an official API. If we want published API documentation, it must be a deliberate,[PublicAPI]-coupled artifact — not auto-scraped from accidental visibility. This new verification project is not built on top ofBuild.PublicApi.cs.D6 — Label-gated breaking changes ✅
No hard CI fail that blocks merge on an intentional break. When the published-package diff shows a breaking delta, the PR gets the
breaking-changelabel → signals a required major bump → we decide when and where to merge (it accumulates toward the yearly major per ADR-0004/AGENTS.md rule #1). The day-to-day committed-snapshot test still fails-and-prompts-re-approval as a normal awareness gate.Open decisions (need consensus)
D4 —
[PublicAPI]annotation: destination vs. phase 1[PublicAPI]decision, not "oh look, it's public." This is the right end state and aligns with D5.[PublicAPI]filter — we reflect for the attribute and feed survivors into itsIncludeTypesoption. We use zero JetBrains.Annotations today; annotating NUKE's large incidentally-public surface is a repo-wide effort and a design commitment.[PublicAPI]+ an annotated-only snapshot as the intended-surface contract, dovetailing with the plugin-architecture work. Open: do we accept the phase-1 noise, or block on annotating first?D7 — CI network dependency for the published-package baseline
Fetching the latest published package at test time adds a network dependency on CI (and a "feed unreachable" failure mode). Options: (a) fetch live each run; (b) cache/pin the baseline package version and bump it deliberately on each release; (c) generate the baseline snapshot at release time and commit it. Leaning (b) — pinned, deliberate bump — to keep CI hermetic and the baseline auditable in git.
Proposed approach (post-consensus)
tests/Fallout.ApiVerificationTests, registered infallout.slnx, inheriting auto-injected test deps viaDirectory.Build.props. AddPublicApiGeneratortoDirectory.Packages.props.[Theory]over(package, framework)pairs; framework list parsed from each csproj'sTargetFrameworks(Dennis's pattern, generalized to N packages).GeneratePublicApi()per assembly →Verifyagainst committedApprovedApi/<Package>.<framework>.verified.txt(awareness net).breaking-changelabel workflow.Nuke.↔Fallout.normalization; uncovered breaks → migrator backlog.FrameworkDisplayName, calver-previewversion/height stamps).Acceptance criteria (draft — finalize after D4/D7)
tests/Fallout.ApiVerificationTestsexists, infallout.slnx, green on a clean checkout.breaking-changelabel workflow (no hard merge-block).docs/note on re-approving snapshots + relation to semver/channel policy.Questions for @dennisdoomen
net8.0-only for the test project while the SUT multi-targets — was multi-TFM snapshotting on the SUT enough, or did you hit pain generating cross-TFM?[PublicAPI]/annotation-based filtering and reject it, or was Mockly's surface small enough that all-public was fine?Assembly.LoadFile+GeneratePublicApiacross TFMs / on CI runners?References
build/Build.PublicApi.csAGENTS.mdcritical rules Security: pin vulnerable transitives (CVE-2026-33116, Scriban) — mirrors upstream #1592 #1–2