From af2dcbb560438ad724a5cc0141687bd0dfe1649c Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:23:37 +0000 Subject: [PATCH 1/2] build: track the public API surface with PublicApiAnalyzers Adds Microsoft.CodeAnalysis.PublicApiAnalyzers with PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt, so any change to the public surface of E4A.PostGuard shows up as an explicit diff in those files instead of slipping into a release. The shipped file is the surface of the released 0.5.0. The one public change made since then, dropping NetworkException.Body (#41), is recorded as a *REMOVED* entry in the unshipped file. The analyzer runs inside dotnet build, so CI needs no new step. Its rules are raised to errors through WarningsAsErrors rather than .editorconfig, because RS0017 is reported against PublicAPI.Shipped.txt and path-based .editorconfig severity does not reach additional files. Closes #49 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 9 ++ src/E4A.PostGuard.csproj | 18 +++ src/PublicAPI.Shipped.txt | 101 +++++++++++++++++ src/PublicAPI.Unshipped.txt | 2 + .../PublicApiTrackingTests.cs | 103 ++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 src/PublicAPI.Shipped.txt create mode 100644 src/PublicAPI.Unshipped.txt create mode 100644 tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index afbe431..f064af6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,15 @@ The Coder workspace ships the net10.0 SDK but not the net8.0 runtime: `dotnet te ## Pinned pg-ffi native binaries `.github/pg-ffi-version` holds one line: the exact `encryption4all/postguard` release tag both workflows download the native libraries from. Bump that file to move to a newer release; don't reintroduce a "newest `pg-ffi-*` release" lookup, it changes what ships without a commit. The Dobby App cannot push `.github/workflows/`, so workflow edits have to go to a maintainer as a patch in a PR comment. +## Tracked public API surface +`src/PublicAPI.Shipped.txt` and `src/PublicAPI.Unshipped.txt` list every public member of `E4A.PostGuard`, checked by Microsoft.CodeAnalysis.PublicApiAnalyzers during `dotnet build` (no separate CI step). + +- Changing the public surface without updating the files fails the build. Add new members to `PublicAPI.Unshipped.txt`; record a removal there as `*REMOVED*`. +- To regenerate a line, build and copy the signature out of the `RS0016` message, which prints it in the file's own format (`Namespace.Type.Member(args) -> ret`). IDEs offer this as a code fix on the diagnostic. +- Severity is raised through `` in `src/E4A.PostGuard.csproj`, not `.editorconfig`. RS0017 and friends are reported against `PublicAPI.Shipped.txt`, and path-based `.editorconfig` severity does not reach additional files, so it silently stays a warning there. +- Both TFMs currently produce the same surface (no `#if` in `src/`), so one pair of files covers them. If a member ever becomes TFM-conditional the files have to be split per TFM. +- **At release time**, move the `PublicAPI.Unshipped.txt` entries into `PublicAPI.Shipped.txt` (applying `*REMOVED*` lines as deletions) and leave the unshipped file with just its `#nullable enable` header. release-please does not do this. + ## API layout - `src/Api/PkgClient.cs`: PKG sign-key + MPK fetch. - `src/Api/CryptifyClient.cs`: chunked Cryptify upload (init / store-chunk / finalize). diff --git a/src/E4A.PostGuard.csproj b/src/E4A.PostGuard.csproj index 5eedadf..8d61866 100644 --- a/src/E4A.PostGuard.csproj +++ b/src/E4A.PostGuard.csproj @@ -18,6 +18,14 @@ git ibe;encryption;postguard;identity-based-encryption README.md + + + $(WarningsAsErrors);RS0016;RS0017;RS0024;RS0025;RS0036;RS0037;RS0041;RS0048;RS0050 @@ -28,6 +36,16 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/PublicAPI.Shipped.txt b/src/PublicAPI.Shipped.txt new file mode 100644 index 0000000..5840fe5 --- /dev/null +++ b/src/PublicAPI.Shipped.txt @@ -0,0 +1,101 @@ +#nullable enable +E4A.PostGuard.Exceptions.NetworkException +E4A.PostGuard.Exceptions.NetworkException.Body.get -> string! +E4A.PostGuard.Exceptions.NetworkException.NetworkException(int statusCode, string! body, string! url) -> void +E4A.PostGuard.Exceptions.NetworkException.StatusCode.get -> int +E4A.PostGuard.Exceptions.NetworkException.Url.get -> string! +E4A.PostGuard.Exceptions.PostGuardException +E4A.PostGuard.Exceptions.PostGuardException.PostGuardException(string! message) -> void +E4A.PostGuard.Exceptions.PostGuardException.PostGuardException(string! message, System.Exception! inner) -> void +E4A.PostGuard.Exceptions.SealException +E4A.PostGuard.Exceptions.SealException.SealException(string! message) -> void +E4A.PostGuard.Models.ApiKeySign +E4A.PostGuard.Models.ApiKeySign.ApiKey.get -> string! +E4A.PostGuard.Models.EncryptInput +E4A.PostGuard.Models.EncryptInput.EncryptInput() -> void +E4A.PostGuard.Models.EncryptInput.Files.get -> System.Collections.Generic.IReadOnlyList! +E4A.PostGuard.Models.EncryptInput.Files.init -> void +E4A.PostGuard.Models.EncryptInput.Recipients.get -> System.Collections.Generic.IReadOnlyList! +E4A.PostGuard.Models.EncryptInput.Recipients.init -> void +E4A.PostGuard.Models.EncryptInput.Sign.get -> E4A.PostGuard.Models.SignMethod! +E4A.PostGuard.Models.EncryptInput.Sign.init -> void +E4A.PostGuard.Models.NotifyOptions +E4A.PostGuard.Models.NotifyOptions.Language.get -> string! +E4A.PostGuard.Models.NotifyOptions.Language.init -> void +E4A.PostGuard.Models.NotifyOptions.Message.get -> string? +E4A.PostGuard.Models.NotifyOptions.Message.init -> void +E4A.PostGuard.Models.NotifyOptions.NotifyOptions() -> void +E4A.PostGuard.Models.NotifyOptions.Recipients.get -> bool +E4A.PostGuard.Models.NotifyOptions.Recipients.init -> void +E4A.PostGuard.Models.NotifyOptions.Sender.get -> bool +E4A.PostGuard.Models.NotifyOptions.Sender.init -> void +E4A.PostGuard.Models.PgFile +E4A.PostGuard.Models.PgFile.Content.get -> System.IO.Stream! +E4A.PostGuard.Models.PgFile.Content.init -> void +E4A.PostGuard.Models.PgFile.Deconstruct(out string! Name, out System.IO.Stream! Content) -> void +E4A.PostGuard.Models.PgFile.Name.get -> string! +E4A.PostGuard.Models.PgFile.Name.init -> void +E4A.PostGuard.Models.PgFile.PgFile(E4A.PostGuard.Models.PgFile! original) -> void +E4A.PostGuard.Models.PgFile.PgFile(string! Name, System.IO.Stream! Content) -> void +E4A.PostGuard.Models.RecipientBuilder +E4A.PostGuard.Models.RecipientBuilder.Email.get -> string! +E4A.PostGuard.Models.RecipientBuilder.ExtraAttribute(string! type, string! value) -> E4A.PostGuard.Models.RecipientBuilder! +E4A.PostGuard.Models.SignMethod +E4A.PostGuard.Models.UploadOptions +E4A.PostGuard.Models.UploadOptions.Notify.get -> E4A.PostGuard.Models.NotifyOptions? +E4A.PostGuard.Models.UploadOptions.Notify.init -> void +E4A.PostGuard.Models.UploadOptions.UploadOptions() -> void +E4A.PostGuard.Models.UploadResult +E4A.PostGuard.Models.UploadResult.Deconstruct(out string! Uuid) -> void +E4A.PostGuard.Models.UploadResult.UploadResult(E4A.PostGuard.Models.UploadResult! original) -> void +E4A.PostGuard.Models.UploadResult.UploadResult(string! Uuid) -> void +E4A.PostGuard.Models.UploadResult.Uuid.get -> string! +E4A.PostGuard.Models.UploadResult.Uuid.init -> void +E4A.PostGuard.PostGuard +E4A.PostGuard.PostGuard.Dispose() -> void +E4A.PostGuard.PostGuard.Encrypt(E4A.PostGuard.Models.EncryptInput! input) -> E4A.PostGuard.Sealed! +E4A.PostGuard.PostGuard.PostGuard(E4A.PostGuard.PostGuardConfig! config) -> void +E4A.PostGuard.PostGuard.Recipient.get -> E4A.PostGuard.RecipientBuilders! +E4A.PostGuard.PostGuard.Sign.get -> E4A.PostGuard.SignBuilders! +E4A.PostGuard.PostGuardConfig +E4A.PostGuard.PostGuardConfig.AllowInsecureUrls.get -> bool +E4A.PostGuard.PostGuardConfig.AllowInsecureUrls.init -> void +E4A.PostGuard.PostGuardConfig.CryptifyUrl.get -> string! +E4A.PostGuard.PostGuardConfig.CryptifyUrl.init -> void +E4A.PostGuard.PostGuardConfig.Headers.get -> System.Collections.Generic.Dictionary? +E4A.PostGuard.PostGuardConfig.Headers.init -> void +E4A.PostGuard.PostGuardConfig.HttpClient.get -> System.Net.Http.HttpClient? +E4A.PostGuard.PostGuardConfig.HttpClient.init -> void +E4A.PostGuard.PostGuardConfig.PkgUrl.get -> string! +E4A.PostGuard.PostGuardConfig.PkgUrl.init -> void +E4A.PostGuard.PostGuardConfig.PostGuardConfig() -> void +E4A.PostGuard.PostGuardConfig.Timeout.get -> System.TimeSpan? +E4A.PostGuard.PostGuardConfig.Timeout.init -> void +E4A.PostGuard.RecipientBuilders +E4A.PostGuard.RecipientBuilders.Email(string! email) -> E4A.PostGuard.Models.RecipientBuilder! +E4A.PostGuard.RecipientBuilders.EmailDomain(string! email) -> E4A.PostGuard.Models.RecipientBuilder! +E4A.PostGuard.RecipientBuilders.RecipientBuilders() -> void +E4A.PostGuard.Sealed +E4A.PostGuard.Sealed.ToBytesAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +E4A.PostGuard.Sealed.UploadAsync(E4A.PostGuard.Models.UploadOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +E4A.PostGuard.SignBuilders +E4A.PostGuard.SignBuilders.ApiKey(string! apiKey) -> E4A.PostGuard.Models.ApiKeySign! +E4A.PostGuard.SignBuilders.SignBuilders() -> void +override E4A.PostGuard.Models.PgFile.Equals(object? obj) -> bool +override E4A.PostGuard.Models.PgFile.GetHashCode() -> int +override E4A.PostGuard.Models.PgFile.ToString() -> string! +override E4A.PostGuard.Models.UploadResult.Equals(object? obj) -> bool +override E4A.PostGuard.Models.UploadResult.GetHashCode() -> int +override E4A.PostGuard.Models.UploadResult.ToString() -> string! +static E4A.PostGuard.Models.PgFile.operator !=(E4A.PostGuard.Models.PgFile? left, E4A.PostGuard.Models.PgFile? right) -> bool +static E4A.PostGuard.Models.PgFile.operator ==(E4A.PostGuard.Models.PgFile? left, E4A.PostGuard.Models.PgFile? right) -> bool +static E4A.PostGuard.Models.UploadResult.operator !=(E4A.PostGuard.Models.UploadResult? left, E4A.PostGuard.Models.UploadResult? right) -> bool +static E4A.PostGuard.Models.UploadResult.operator ==(E4A.PostGuard.Models.UploadResult? left, E4A.PostGuard.Models.UploadResult? right) -> bool +virtual E4A.PostGuard.Models.PgFile.$() -> E4A.PostGuard.Models.PgFile! +virtual E4A.PostGuard.Models.PgFile.EqualityContract.get -> System.Type! +virtual E4A.PostGuard.Models.PgFile.Equals(E4A.PostGuard.Models.PgFile? other) -> bool +virtual E4A.PostGuard.Models.PgFile.PrintMembers(System.Text.StringBuilder! builder) -> bool +virtual E4A.PostGuard.Models.UploadResult.$() -> E4A.PostGuard.Models.UploadResult! +virtual E4A.PostGuard.Models.UploadResult.EqualityContract.get -> System.Type! +virtual E4A.PostGuard.Models.UploadResult.Equals(E4A.PostGuard.Models.UploadResult? other) -> bool +virtual E4A.PostGuard.Models.UploadResult.PrintMembers(System.Text.StringBuilder! builder) -> bool diff --git a/src/PublicAPI.Unshipped.txt b/src/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..0dfd81c --- /dev/null +++ b/src/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable +*REMOVED*E4A.PostGuard.Exceptions.NetworkException.Body.get -> string! diff --git a/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs new file mode 100644 index 0000000..15cb054 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; + +namespace E4A.PostGuard.Tests; + +/// +/// The public surface is tracked in src/PublicAPI.Shipped.txt and +/// src/PublicAPI.Unshipped.txt and guarded by Microsoft.CodeAnalysis.PublicApiAnalyzers. +/// The analyzer only guards what it is wired up to see, so these tests cover the wiring itself +/// and check the tracked types against the assembly that ships. +/// +public class PublicApiTrackingTests +{ + [Fact] + public void EveryExportedType_IsDeclaredInThePublicApiFiles() + { + var declared = DeclaredEntries(); + var declaredTypes = declared.Where(entry => !entry.Contains("->")).ToHashSet(StringComparer.Ordinal); + + var exported = typeof(PostGuard).Assembly.GetExportedTypes() + .Select(DeclaredName) + .ToHashSet(StringComparer.Ordinal); + + var undeclared = exported.Except(declaredTypes).Order(StringComparer.Ordinal).ToArray(); + var stale = declaredTypes.Except(exported).Order(StringComparer.Ordinal).ToArray(); + + Assert.True( + undeclared.Length == 0, + $"public types missing from the PublicAPI files: {string.Join(", ", undeclared)}"); + Assert.True( + stale.Length == 0, + $"PublicAPI files declare types the assembly no longer exports: {string.Join(", ", stale)}"); + } + + [Fact] + public void ShippedFile_TracksNullability() + { + // Without the header the analyzer records every reference type as oblivious, so a + // nullable-annotation change would slip through unreviewed. + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), "src", "PublicAPI.Shipped.txt")); + Assert.Equal("#nullable enable", lines.FirstOrDefault()); + } + + [Fact] + public void Csproj_KeepsTheAnalyzerWiredUpAndFailingTheBuild() + { + var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "E4A.PostGuard.csproj")); + + Assert.Contains("Microsoft.CodeAnalysis.PublicApiAnalyzers", csproj, StringComparison.Ordinal); + Assert.Contains("", csproj, StringComparison.Ordinal); + Assert.Contains("", csproj, StringComparison.Ordinal); + + // Left as warnings, an undeclared API change scrolls past in a green build. + var warningsAsErrors = csproj.Split('\n').Single(line => line.Contains("")); + Assert.Contains("RS0016", warningsAsErrors, StringComparison.Ordinal); + Assert.Contains("RS0017", warningsAsErrors, StringComparison.Ordinal); + } + + /// Shipped plus unshipped additions, minus the entries marked *REMOVED*. + private static HashSet DeclaredEntries() + { + const string removedPrefix = "*REMOVED*"; + var root = Path.Combine(RepoRoot(), "src"); + + var entries = new[] { "PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt" } + .SelectMany(name => File.ReadAllLines(Path.Combine(root, name))) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith('#')) + .ToArray(); + + var declared = entries + .Where(line => !line.StartsWith(removedPrefix, StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var removed in entries.Where(line => line.StartsWith(removedPrefix, StringComparison.Ordinal))) + { + declared.Remove(removed[removedPrefix.Length..]); + } + + return declared; + } + + /// The name a type is written under in the PublicAPI files. + private static string DeclaredName(Type type) + { + var name = type.FullName!.Replace('+', '.'); + var arity = name.IndexOf('`'); + return arity < 0 + ? name + : $"{name[..arity]}<{string.Join(", ", type.GetGenericArguments().Select(argument => argument.Name))}>"; + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "E4A.PostGuard.slnx"))) + { + dir = dir.Parent; + } + + Assert.NotNull(dir); + return dir!.FullName; + } +} From b2e1dd91fd075a064061c619cbfd3fbf5a924fdd Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:42:04 +0000 Subject: [PATCH 2/2] test: parse the csproj and cover both API files for nullability Two review nits on the tracking tests, both verified by probe: - ShippedFile_TracksNullability only checked PublicAPI.Shipped.txt, but the hazard applies to PublicAPI.Unshipped.txt too, and that is the file contributors edit on every API change. Dropping its #nullable enable header builds green with 0 warnings, so nothing caught it. Now a [Theory] over both files; the unshipped case fails on that probe. - Csproj_KeepsTheAnalyzerWiredUpAndFailingTheBuild matched raw csproj text, so it red-lit on edits that left the wiring intact: reformatting an element to the "" form that dotnet format and the IDEs produce, or adding a second WarningsAsErrors element, which made .Single() throw. Parses with XDocument instead. Same guarantee; unwiring AdditionalFiles still fails it. Also drop the one bold span added to CLAUDE.md, which has none elsewhere. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- .../PublicApiTrackingTests.cs | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f064af6..24b2d4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ The Coder workspace ships the net10.0 SDK but not the net8.0 runtime: `dotnet te - To regenerate a line, build and copy the signature out of the `RS0016` message, which prints it in the file's own format (`Namespace.Type.Member(args) -> ret`). IDEs offer this as a code fix on the diagnostic. - Severity is raised through `` in `src/E4A.PostGuard.csproj`, not `.editorconfig`. RS0017 and friends are reported against `PublicAPI.Shipped.txt`, and path-based `.editorconfig` severity does not reach additional files, so it silently stays a warning there. - Both TFMs currently produce the same surface (no `#if` in `src/`), so one pair of files covers them. If a member ever becomes TFM-conditional the files have to be split per TFM. -- **At release time**, move the `PublicAPI.Unshipped.txt` entries into `PublicAPI.Shipped.txt` (applying `*REMOVED*` lines as deletions) and leave the unshipped file with just its `#nullable enable` header. release-please does not do this. +- At release time, move the `PublicAPI.Unshipped.txt` entries into `PublicAPI.Shipped.txt` (applying `*REMOVED*` lines as deletions) and leave the unshipped file with just its `#nullable enable` header. release-please does not do this. ## API layout - `src/Api/PkgClient.cs`: PKG sign-key + MPK fetch. diff --git a/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs index 15cb054..a8c1ed1 100644 --- a/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs +++ b/tests/E4A.PostGuard.Tests/PublicApiTrackingTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Xml.Linq; namespace E4A.PostGuard.Tests; @@ -31,30 +32,43 @@ public void EveryExportedType_IsDeclaredInThePublicApiFiles() $"PublicAPI files declare types the assembly no longer exports: {string.Join(", ", stale)}"); } - [Fact] - public void ShippedFile_TracksNullability() + [Theory] + [InlineData("PublicAPI.Shipped.txt")] + [InlineData("PublicAPI.Unshipped.txt")] + public void ApiFile_TracksNullability(string fileName) { // Without the header the analyzer records every reference type as oblivious, so a - // nullable-annotation change would slip through unreviewed. - var lines = File.ReadAllLines(Path.Combine(RepoRoot(), "src", "PublicAPI.Shipped.txt")); + // nullable-annotation change would slip through unreviewed. Dropping it from the + // unshipped file still builds green, so nothing but this test catches that. + var lines = File.ReadAllLines(Path.Combine(RepoRoot(), "src", fileName)); Assert.Equal("#nullable enable", lines.FirstOrDefault()); } [Fact] public void Csproj_KeepsTheAnalyzerWiredUpAndFailingTheBuild() { - var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "E4A.PostGuard.csproj")); + // Parsed rather than substring-matched: reformatting an element, or moving + // WarningsAsErrors into a second PropertyGroup, leaves the wiring fully intact + // and must not red-light this test. + var csproj = XDocument.Load(Path.Combine(RepoRoot(), "src", "E4A.PostGuard.csproj")); - Assert.Contains("Microsoft.CodeAnalysis.PublicApiAnalyzers", csproj, StringComparison.Ordinal); - Assert.Contains("", csproj, StringComparison.Ordinal); - Assert.Contains("", csproj, StringComparison.Ordinal); + Assert.Contains("Microsoft.CodeAnalysis.PublicApiAnalyzers", Includes(csproj, "PackageReference")); + Assert.Contains("PublicAPI.Shipped.txt", Includes(csproj, "AdditionalFiles")); + Assert.Contains("PublicAPI.Unshipped.txt", Includes(csproj, "AdditionalFiles")); // Left as warnings, an undeclared API change scrolls past in a green build. - var warningsAsErrors = csproj.Split('\n').Single(line => line.Contains("")); + var warningsAsErrors = string.Join(';', csproj.Descendants("WarningsAsErrors").Select(element => element.Value)); Assert.Contains("RS0016", warningsAsErrors, StringComparison.Ordinal); Assert.Contains("RS0017", warningsAsErrors, StringComparison.Ordinal); } + /// The Include attribute of every item in the project. + private static string[] Includes(XDocument csproj, string elementName) => + csproj.Descendants(elementName) + .Select(element => (string?)element.Attribute("Include")) + .OfType() + .ToArray(); + /// Shipped plus unshipped additions, minus the entries marked *REMOVED*. private static HashSet DeclaredEntries() {