From 8d978b6625b2181eb7f1a3fbebd58a9d0a983a01 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 06:44:11 +0200 Subject: [PATCH 1/8] docs: plan enabling package validation across all packages Package validation is enabled only on the core package and has no baseline anywhere, so nothing distinguishes the 0.7.0 breaking changes that break a 0.6.0 consumer from the ones that only break someone tracking main. Validation also hangs off Pack, which no workflow but release-on-nuget runs, so a shape break would surface in the publish job of an already-tagged release rather than during review. Measured against a 0.6.0 baseline, the whole solution produces exactly two consumer-visible breaks: the deliberate MetadataKind.Array and MetadataKind.Object renumbering. The rest of the breaking-change bullets are either behavioral, which ApiCompat cannot see, or describe removals of types that never shipped in 0.6.0. That makes the pre-release audit cheap enough to be worth doing now, when it is most useful. The blocker is strong naming: published packages are signed with a release-only secret, so an ordinary build fails the baseline with CP0003 on identity alone. Defaulting SignAssembly, PublicSign and the committed public key in src/Directory.Build.props resolves it and keeps the gate reachable as a bare dotnet pack, which is what makes it a feedback loop for coding agents rather than a CI-only check. The release workflow passes AssemblyOriginatorKeyFile as a global property, so the fallback disables itself and private-key signing is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 112 +++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 ai-plans/0082-enable-package-validation.md diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md new file mode 100644 index 0000000..f65f79f --- /dev/null +++ b/ai-plans/0082-enable-package-validation.md @@ -0,0 +1,112 @@ +# Enable Package Validation Across All Packages + +## Rationale + +`EnablePackageValidation` is set only on `Light.PortableResults`, and no `PackageValidationBaselineVersion` is configured anywhere. Package validation hangs off the `Pack` target, and `build-and-test.yml` never packs — the only workflow that does is `release-on-nuget.yml`, which fires on `release: published`. A shape break would therefore surface in the publish job of an already-tagged release, never during review. + +The gate should also run locally with no arguments. A check that exists only in CI is a check that AI coding agents and developers discover after pushing; one that a bare `dotnet pack` reproduces is a feedback loop they can close before committing. That constraint shapes the design below more than the CI job does. + +Without a baseline the property is close to decorative: it activates only the compatible-framework check, which has teeth for the two multi-targeted packages and is a no-op for the other five. The baseline check is the part that earns its keep, and 0.7.0 is the release that most needs it — the core package's release notes carry roughly twenty-five breaking-change bullets, and nothing today distinguishes the ones that break a 0.6.0 consumer from the ones that only break someone tracking `main`. Measured against a 0.6.0 baseline, the entire solution produces exactly two consumer-visible breaks. Turning the baseline on before the release converts that distinction from prose into a reviewed, enforced artifact; turning it on afterwards means the release with the largest breaking-change surface is the one release that never gets audited. + +## Acceptance Criteria + +- [ ] Every packable project under `src/` builds with package validation enabled. The non-packable source generator is unaffected. +- [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. +- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. The inner `dotnet build` and `dotnet test` loop is unaffected. +- [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. +- [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. +- [ ] Strong-naming the `src` assemblies by default leaves the test suite and the Native AOT sample publish green. +- [ ] `AGENTS.md` documents the local command, its expected clean output, and the offline escape hatch. +- [ ] The only committed suppressions are the two `MetadataKind` enum-value changes already named in the release notes. Every other break listed there is confirmed to be either behavioral or invisible to a 0.6.0 consumer. +- [ ] The gate is proven with an injected regression: removing or renaming a public member fails the build with the corresponding `CP` diagnostic. +- [ ] The post-release step — bump the baseline to `0.7.0` and delete the suppression file — is recorded where it will be found at release time. + +## Technical Details + +### What the baseline does and does not catch + +ApiCompat compares assembly shape. Most of the 0.7.0 breaking-change bullets are behavioral — decimal serializing as a JSON number, header quoting, null CloudEvents attributes being omitted, the new default-instance guard throwing — and none of them will ever produce a diagnostic. The prose release notes stay load-bearing; validation makes only the shape subset enforceable. + +A large share of the remaining bullets describe breaks against intermediate states on `main` rather than against 0.6.0. `CanonicalFloatingPointFormatter`, `PortableResultsJsonContracts`, `MustNotBeDefaultInstance` and `HttpHeaderValueFormatter` were never in the published 0.6.0 assembly, so their removal or renaming cannot break a consumer. This is why the measured break count is two rather than twenty-five, and it is worth stating in the release notes review rather than treating the low number as a tooling failure. + +The measured result against `0.6.0`, across every package and both assets, is the complete suppression file at `src/Light.PortableResults/CompatibilitySuppressions.xml`: + +```xml + + + CP0011 + F:Light.PortableResults.Metadata.MetadataKind.Array + lib/netstandard2.0/Light.PortableResults.dll + lib/netstandard2.0/Light.PortableResults.dll + true + + +``` + +Both are the deliberate renumbering to 200 and 201 that reserves 16–199 for future primitive kinds. Only `netstandard2.0` entries are required; the `net10.0` asset needs none. Generate the file with `/p:ApiCompatGenerateSuppressionFile=true` rather than hand-writing it, and treat any third entry appearing during implementation as a finding to escalate, not to suppress. + +Unnecessary suppressions fail the build by default, which is load-bearing for the handover below: once the baseline moves to `0.7.0`, these two entries become stale and the build fails until they are deleted. The cleanup cannot be forgotten silently, so do not set `ApiCompatPermitUnnecessarySuppressions`. + +Confirm the gate is real before trusting a clean run. A `0.4.0` baseline for `Light.PortableResults.AspNetCore.MinimalApis` correctly reports `CP0001` for `PortableResultsEndpointExtensions`, the type extracted into the OpenApi package in 0.5.0; that check, or an equivalent injected regression, satisfies the proof criterion. + +All seven packages published a `0.6.0`, so a single `PackageValidationBaselineVersion` in `src/Directory.Build.props` covers them. `AspNetCore.OpenApi` and `Validation.OpenApi` first shipped in 0.5.0, which does not affect a 0.6.0 baseline. The baseline `PackageDownload` does not enter `packages.lock.json` and does not trip `RestoreLockedMode`, so no lock file changes are expected — a lock file diff during implementation means something else moved. + +### Strong naming + +This is the blocker. Published packages are strong-named through an SNK that exists only as a GitHub Actions secret, so ordinary builds are unsigned and every package fails the baseline on identity alone: + +``` +CP0003: [Baseline] ... assembly public key token 'cc46d8340219f3bd' does not match ... 'null' +``` + +`Light.PortableResults.Public.snk` is already committed at the repository root and carries the matching token. Do not suppress `CP0003` instead — that would hide a real signing regression at the one point where it is observable. + +Passing the signing properties on the command line would work, but it puts the gate behind an invocation nobody remembers, which defeats the local-loop goal. Default them in `src/Directory.Build.props` instead, keyed off whether a key file was supplied: + +```xml + +true +true +true +$(MSBuildThisFileDirectory)../Light.PortableResults.Public.snk +``` + +The release workflow passes `AssemblyOriginatorKeyFile` as a global property, which cannot be overridden from a props file, so `UsePublicSigningKey` stays empty there and `PublicSign` is never set — the release build signs with the private key exactly as it does today. Verify this with `-getProperty:PublicSign` under both invocations rather than by inspection; it is the one place where a mistake ships unsigned-but-strong-named packages to NuGet. Do not route the guard through `SignAssembly`: the release workflow sets that too, so it cannot distinguish the two cases. + +Use `$(MSBuildThisFileDirectory)` rather than the `../../` that `release-on-nuget.yml` uses. The relative form resolves against the project file and only works because every packable project sits exactly two levels down. + +The consequence is that all `src` assemblies become strong-named in every build, not just when packing. That is a real change and the reason the acceptance criteria call for the test suite and the AOT sample: public-signed assemblies carry the strong name without a valid signature, which .NET Core does not verify but which would break `InternalsVisibleTo` if the solution used it — it does not. It is also what lets CI validate with a `--no-build` pack over the existing build output, as described below. Packages produced outside the release workflow must never be pushed. + +### CI shape and the local loop + +Package validation hangs off `Pack`, so `dotnet build` and `dotnet test` stay untouched and the inner loop keeps its current speed. Agents and developers opt in with: + +```shell +dotnet pack ./Light.PortableResults.slnx -c Release +``` + +No properties, no script, no separate target — the same command CI runs, and on a warm NuGet cache it completes in a few seconds. Document it in `AGENTS.md` next to the existing build and test guidance, including that a clean run prints only `Successfully created package` lines, and that a break prints a `CP` diagnostic naming the API. + +Setting a baseline adds a `PackageDownload` for the seven 0.6.0 packages, so a cold restore needs network access; the CI NuGet cache already covers this. `-p:DisablePackageBaselineValidation=true` is the escape hatch for working offline, and it belongs in the `AGENTS.md` note so it is not rediscovered as a workaround for a genuine break. + +In `build-and-test.yml` this is a step, not a job. Because signing is defaulted in the props file, the existing build job already produces the strong-named assemblies validation needs, so a `--no-build` pack reuses them and adds a couple of seconds: + +```shell +dotnet pack ./Light.PortableResults.slnx --configuration Release --no-build /p:ContinuousIntegrationBuild=true +``` + +Verified: `--no-build` still runs `RunPackageValidation`. Nothing here warrants the separate-job treatment `native-aot-publish` gets — that split exists because native compilation is slow, which does not apply. Put the step after the test run so a validation failure does not mask a test failure. The packages it produces are a byproduct and must not be pushed. + +### Release handover + +After 0.7.0 is tagged, `PackageValidationBaselineVersion` moves to `0.7.0` and `src/Light.PortableResults/CompatibilitySuppressions.xml` is deleted in the same commit, so the 0.8.0 cycle starts from a clean baseline with no suppressions. The repository has no release checklist document today, so record this as a follow-up issue on #77 rather than inventing one as part of this change. + +### Deliberately out of scope + +- Package validation for `Light.PortableResults.Validation.OpenApi.SourceGeneration`. It sets `IsPackable=false`, so validation never runs for it and nothing needs to opt it out. +- Runtime-specific asset validation. No package ships RID-specific assets. +- Reverting or reworking the `MetadataKind` renumbering. It is intentional and already documented; the suppression records it. +- Any change to the release signing model. Ordinary builds do gain public signing, but the private key, the workflow and the published artifacts are untouched. +- A dedicated MSBuild target, script or `dotnet` tool wrapping the local check. `dotnet pack -c Release` is the whole interface. +- Making behavioral breaks enforceable. ApiCompat cannot see them and the release notes remain the record. +- The remaining v0.7.0 preparations. This is item 3 of #77; Native AOT compatibility is #78 and the default-result write guard is #80. From 1dc8312433f96806a687aaa438e79b98387a3df4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 06:56:30 +0200 Subject: [PATCH 2/8] docs: correct the restore-time cost of the package validation baseline The plan claimed the inner build and test loop was unaffected while also noting the baseline download, which cannot both be true. The SDK injects the PackageDownload from an evaluation-time ItemGroup in Microsoft.NET.ApiCompat.targets rather than from a target, so it participates in restore whether or not Pack runs: a plain dotnet restore fetches all seven 0.6.0 packages. Only validation execution hangs off Pack, so the accurate claim is that the loop is untouched after restore. Gating the baseline on packing was considered and rejected. It would depend on the undocumented _IsPacking property and would leave the CI restore without the baselines the --no-build pack step needs, for one cold download of seven small packages that NuGet and the CI cache keep. The opt-out is documented for restore instead. The same ItemGroup is conditioned on DisablePackageBaselineValidation, so passing it as a global property suppresses the download and the baseline check together; with the baselines purged from the cache, restore then fetches nothing and build and the full suite still pass. The lock file findings move here as well: no Download entry appears even after a forced restore, and locked mode is unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index f65f79f..399e78c 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -12,7 +12,8 @@ Without a baseline the property is close to decorative: it activates only the co - [ ] Every packable project under `src/` builds with package validation enabled. The non-packable source generator is unaffected. - [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. -- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. The inner `dotnet build` and `dotnet test` loop is unaffected. +- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so their cost is unchanged; restore gains the seven baseline packages. +- [ ] A single documented property suppresses both the baseline download at restore and the validation at pack, so the solution restores, builds and tests offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. - [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. - [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. - [ ] Strong-naming the `src` assemblies by default leaves the test suite and the Native AOT sample publish green. @@ -49,7 +50,7 @@ Unnecessary suppressions fail the build by default, which is load-bearing for th Confirm the gate is real before trusting a clean run. A `0.4.0` baseline for `Light.PortableResults.AspNetCore.MinimalApis` correctly reports `CP0001` for `PortableResultsEndpointExtensions`, the type extracted into the OpenApi package in 0.5.0; that check, or an equivalent injected regression, satisfies the proof criterion. -All seven packages published a `0.6.0`, so a single `PackageValidationBaselineVersion` in `src/Directory.Build.props` covers them. `AspNetCore.OpenApi` and `Validation.OpenApi` first shipped in 0.5.0, which does not affect a 0.6.0 baseline. The baseline `PackageDownload` does not enter `packages.lock.json` and does not trip `RestoreLockedMode`, so no lock file changes are expected — a lock file diff during implementation means something else moved. +All seven packages published a `0.6.0`, so a single `PackageValidationBaselineVersion` in `src/Directory.Build.props` covers them. `AspNetCore.OpenApi` and `Validation.OpenApi` first shipped in 0.5.0, which does not affect a 0.6.0 baseline. Setting the baseline has restore-time consequences, covered under the local loop below. ### Strong naming @@ -79,7 +80,7 @@ The consequence is that all `src` assemblies become strong-named in every build, ### CI shape and the local loop -Package validation hangs off `Pack`, so `dotnet build` and `dotnet test` stay untouched and the inner loop keeps its current speed. Agents and developers opt in with: +Validation *runs* on `Pack`, so no `CP` diagnostic can appear during `dotnet build` or `dotnet test` and their cost is unchanged. Agents and developers opt in with: ```shell dotnet pack ./Light.PortableResults.slnx -c Release @@ -87,7 +88,19 @@ dotnet pack ./Light.PortableResults.slnx -c Release No properties, no script, no separate target — the same command CI runs, and on a warm NuGet cache it completes in a few seconds. Document it in `AGENTS.md` next to the existing build and test guidance, including that a clean run prints only `Successfully created package` lines, and that a break prints a `CP` diagnostic naming the API. -Setting a baseline adds a `PackageDownload` for the seven 0.6.0 packages, so a cold restore needs network access; the CI NuGet cache already covers this. `-p:DisablePackageBaselineValidation=true` is the escape hatch for working offline, and it belongs in the `AGENTS.md` note so it is not rediscovered as a workaround for a genuine break. +Baseline *acquisition* is a different matter and does reach the ordinary loop. The SDK injects the `PackageDownload` from an evaluation-time `ItemGroup` in `Microsoft.NET.ApiCompat.targets`, not from a target, so it participates in restore whether or not `Pack` ever runs. A plain `dotnet restore` — and therefore `dotnet build` or `dotnet test` with implicit restore — fetches all seven 0.6.0 packages. Do not describe the inner loop as untouched; the accurate statement is that it is untouched *after restore*. + +That cost is one cold download of seven small packages, cached thereafter, and CI already caches NuGet, so it does not justify a redesign. Gating the baseline on whether packing is underway would mean depending on the undocumented `_IsPacking` property and would leave the CI restore without the baselines that the `--no-build` pack step needs. Accept the download and document the opt-out instead. + +The same `ItemGroup` is conditioned on `DisablePackageBaselineValidation`, so one property covers both halves: + +```shell +dotnet build ./Light.PortableResults.slnx -c Release -p:DisablePackageBaselineValidation=true +``` + +Passed as a global property it suppresses the download at restore as well as the baseline check at pack. Verified with the baseline packages purged from the cache: restore fetches nothing, and build and the full test suite still pass. This belongs in the `AGENTS.md` note as the offline escape hatch, worded so it is not mistaken for a way to silence a genuine break. + +Lock files are not involved. The baseline `PackageDownload` does not appear in `packages.lock.json` even after `dotnet restore --force`, and locked-mode restore under `ContinuousIntegrationBuild=true` succeeds unchanged — so there is no lock file maintenance and no `NU1004` risk. Determinism does not depend on the lock file either, because the injected download pins an exact version range. A lock file diff appearing during implementation means something else moved. In `build-and-test.yml` this is a step, not a job. Because signing is defaulted in the props file, the existing build job already produces the strong-named assemblies validation needs, so a `--no-build` pack reuses them and adds a couple of seconds: From d097ef14da3e6cb3ae10c3952a07c435bb19763d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:02:55 +0200 Subject: [PATCH 3/8] docs: describe package validation success by exit code, not by output The plan told AGENTS.md to document a clean run as printing only Successfully-created-package lines. Pack is incremental, so that is wrong in both directions: a second run with no source changes prints no package lines at all, and a --no-build repeat on an up-to-date tree prints nothing whatsoever and exits 0. An agent looking for those lines would read a silent, correct run as a failure. Success is now described as exit code zero and no CP diagnostics. The non-packable sample warning is removed rather than documented around. Microsoft.NET.Sdk.Web.ProjectSystem.props turns WarnOnPackingNonPackableProject on for web projects, which the sample is, so it alone warns on a solution pack; setting the property to false silences it. That warning is in the release workflow's pack output today, and removing it is what leaves a correct --no-build run silent instead of showing a lone warning as its only output. Incremental packing was checked separately and does not weaken the gate: altering a baseline member is caught on the next pack and the next --no-build pack. The plan records that most MetadataKind members are new in 0.7.0, so an injected regression has to target one of the seven the baseline actually contains. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index 399e78c..f73e831 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -17,9 +17,10 @@ Without a baseline the property is close to decorative: it activates only the co - [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. - [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. - [ ] Strong-naming the `src` assemblies by default leaves the test suite and the Native AOT sample publish green. -- [ ] `AGENTS.md` documents the local command, its expected clean output, and the offline escape hatch. +- [ ] Packing the solution emits no warning for the non-packable sample, in the release workflow as well as locally. +- [ ] `AGENTS.md` documents the local command, how to tell success from failure, and the offline escape hatch. - [ ] The only committed suppressions are the two `MetadataKind` enum-value changes already named in the release notes. Every other break listed there is confirmed to be either behavioral or invisible to a 0.6.0 consumer. -- [ ] The gate is proven with an injected regression: removing or renaming a public member fails the build with the corresponding `CP` diagnostic. +- [ ] The gate is proven with an injected regression: removing or renaming a public member fails with the corresponding `CP` diagnostic and a non-zero exit code, on an incremental pack as well as a clean one. - [ ] The post-release step — bump the baseline to `0.7.0` and delete the suppression file — is recorded where it will be found at release time. ## Technical Details @@ -86,7 +87,20 @@ Validation *runs* on `Pack`, so no `CP` diagnostic can appear during `dotnet bui dotnet pack ./Light.PortableResults.slnx -c Release ``` -No properties, no script, no separate target — the same command CI runs, and on a warm NuGet cache it completes in a few seconds. Document it in `AGENTS.md` next to the existing build and test guidance, including that a clean run prints only `Successfully created package` lines, and that a break prints a `CP` diagnostic naming the API. +No properties, no script, no separate target — the same command CI runs, and on a warm NuGet cache it completes in a few seconds. Document it in `AGENTS.md` next to the existing build and test guidance. + +Describe success by exit code and the absence of `CP` diagnostics, never by asserting expected output. Pack is incremental, so the console is not a reliable success signal: a first run prints restore lines, build lines and `Successfully created package` lines, a second run with no source changes prints build lines and no package lines at all, and a `--no-build` repeat on an up-to-date tree prints *nothing whatsoever* and exits 0. An agent told to look for `Successfully created package` would read that silent, correct run as a failure. Measured, on a clean tree: `--no-build` produces zero output lines and exit 0; with a break present, exit 1 and `error CP0011: ...` naming the API. + +Incremental packing does not weaken the gate. Verified by changing `MetadataKind.String` from `4` — a member that exists in the 0.6.0 baseline — on an already-packed, up-to-date tree: the break is reported on the next `dotnet pack` and on the next `--no-build` pack. Note when constructing such a check that most `MetadataKind` members are new in 0.7.0, so altering them proves nothing; the baseline only contains `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array` and `Object`. + +Remove the non-packable warning rather than documenting around it. Packing the solution today prints: + +``` +warning : This project cannot be packaged because packaging has been disabled. ... + [samples/NativeAotMovieRating/NativeAotMovieRating.csproj] +``` + +`Microsoft.NET.Sdk.Web.ProjectSystem.props` sets `WarnOnPackingNonPackableProject` to `true` for web projects, which the sample is; the other non-packable projects use non-web SDKs and stay quiet. Setting the property to `false` in the sample silences it. This is worth fixing on its own account — the warning is in the release workflow's pack output today — and it is what leaves a correct `--no-build` run genuinely silent instead of showing a lone warning as its only output. Baseline *acquisition* is a different matter and does reach the ordinary loop. The SDK injects the `PackageDownload` from an evaluation-time `ItemGroup` in `Microsoft.NET.ApiCompat.targets`, not from a target, so it participates in restore whether or not `Pack` ever runs. A plain `dotnet restore` — and therefore `dotnet build` or `dotnet test` with implicit restore — fetches all seven 0.6.0 packages. Do not describe the inner loop as untouched; the accurate statement is that it is untouched *after restore*. @@ -121,5 +135,6 @@ After 0.7.0 is tagged, `PackageValidationBaselineVersion` moves to `0.7.0` and ` - Reverting or reworking the `MetadataKind` renumbering. It is intentional and already documented; the suppression records it. - Any change to the release signing model. Ordinary builds do gain public signing, but the private key, the workflow and the published artifacts are untouched. - A dedicated MSBuild target, script or `dotnet` tool wrapping the local check. `dotnet pack -c Release` is the whole interface. +- Making the sample packable, or removing it from the solution. Only its warning is silenced. - Making behavioral breaks enforceable. ApiCompat cannot see them and the release notes remain the record. - The remaining v0.7.0 preparations. This is item 3 of #77; Native AOT compatibility is #78 and the default-result write guard is #80. From 5eab0623b844c75d99142828d43c870a8817c2e7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:06:04 +0200 Subject: [PATCH 4/8] docs: cover the validation baselines in the CI NuGet cache key The plan claimed the existing CI cache already absorbs the baseline download. It does not, and the gap does not close on its own. The cache-nuget action keys on hashFiles('**/packages.lock.json'), and the baseline PackageDownload provably leaves those files untouched, so the key is unchanged by this work. actions/cache skips its post-job save on an exact key hit, so the pre-existing entry - which predates the baseline and lacks the seven packages - would be restored on every run, the baselines re-downloaded every time, and the entry never refreshed. This affects every run rather than only fresh runners, and release-on-nuget.yml shares the same composite action. The key now hashes src/Directory.Build.props alongside the lock files. That is the correct dependency set: lock files describe what PackageReference resolves to, and the props file is where PackageValidationBaselineVersion lives. It rotates once when this change lands, so the refreshed entry is saved with the baselines in it, and again when the baseline moves to 0.7.0 at release. Hard-coding the version into the key was rejected as duplication that goes stale the first time someone bumps one without the other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index f73e831..c6227b7 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -14,6 +14,7 @@ Without a baseline the property is close to decorative: it activates only the co - [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. - [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so their cost is unchanged; restore gains the seven baseline packages. - [ ] A single documented property suppresses both the baseline download at restore and the validation at pack, so the solution restores, builds and tests offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. +- [ ] The CI NuGet cache key covers the baseline packages, so they are cached rather than re-downloaded on every run, and the key rotates when the baseline version changes. - [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. - [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. - [ ] Strong-naming the `src` assemblies by default leaves the test suite and the Native AOT sample publish green. @@ -104,7 +105,17 @@ warning : This project cannot be packaged because packaging has been disabled. . Baseline *acquisition* is a different matter and does reach the ordinary loop. The SDK injects the `PackageDownload` from an evaluation-time `ItemGroup` in `Microsoft.NET.ApiCompat.targets`, not from a target, so it participates in restore whether or not `Pack` ever runs. A plain `dotnet restore` — and therefore `dotnet build` or `dotnet test` with implicit restore — fetches all seven 0.6.0 packages. Do not describe the inner loop as untouched; the accurate statement is that it is untouched *after restore*. -That cost is one cold download of seven small packages, cached thereafter, and CI already caches NuGet, so it does not justify a redesign. Gating the baseline on whether packing is underway would mean depending on the undocumented `_IsPacking` property and would leave the CI restore without the baselines that the `--no-build` pack step needs. Accept the download and document the opt-out instead. +That cost is one cold download of seven small packages, cached thereafter, so it does not justify a redesign. Gating the baseline on whether packing is underway would mean depending on the undocumented `_IsPacking` property and would leave the CI restore without the baselines that the `--no-build` pack step needs. Accept the download and document the opt-out instead. + +The existing CI cache does not cover it without a change, and this is not self-correcting. `.github/actions/cache-nuget/action.yml` keys on `hashFiles('**/packages.lock.json')`, and the baseline `PackageDownload` provably does not alter those files, so the key is unchanged by this work. `actions/cache` skips its post-job save on an exact key hit, so the pre-existing entry — which predates the baseline and does not contain the seven packages — would be restored on every run, the baselines re-downloaded every time, and the entry never refreshed. This affects every run, not only fresh runners, and it applies to `release-on-nuget.yml` too, which uses the same composite action. + +Add the file that declares the baseline to the key: + +```yaml +key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json', 'src/Directory.Build.props') }} +``` + +That is the correct dependency set rather than a workaround: lock files describe what `PackageReference` resolves to, and `src/Directory.Build.props` is where `PackageValidationBaselineVersion` lives, so together they cover everything restore fetches. It rotates the key once when this change lands, so the refreshed entry is saved with the baselines in it, and rotates again when the baseline moves to 0.7.0 at release. Do not hard-code the baseline version into the cache key instead: it would duplicate a value that already exists in the props file and would silently go stale the first time someone bumps one without the other. The occasional unnecessary cache rebuild when that props file changes for an unrelated reason is the accepted cost. The same `ItemGroup` is conditioned on `DisablePackageBaselineValidation`, so one property covers both halves: From 182a2bfc25704a18e4e2b480f59e227f35f5bf28 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:15:57 +0200 Subject: [PATCH 5/8] docs: qualify the unchanged cost as post-restore The acceptance criterion still claimed dotnet build and dotnet test cost is unchanged. A cold run of either performs an implicit restore and therefore pays for the baseline packages, so only compilation and test execution are unaffected. The Technical Details already drew that line; the criterion now matches it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index c6227b7..9f835d0 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -12,7 +12,7 @@ Without a baseline the property is close to decorative: it activates only the co - [ ] Every packable project under `src/` builds with package validation enabled. The non-packable source generator is unaffected. - [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. -- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so their cost is unchanged; restore gains the seven baseline packages. +- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so compilation and test execution cost is unchanged after restore; restore itself gains the seven baseline packages, which a cold `dotnet build` or `dotnet test` pays for through implicit restore. - [ ] A single documented property suppresses both the baseline download at restore and the validation at pack, so the solution restores, builds and tests offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. - [ ] The CI NuGet cache key covers the baseline packages, so they are cached rather than re-downloaded on every run, and the key rotates when the baseline version changes. - [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. From 256813a1de28b28d2638cf5a9a761555289d387b Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:18:53 +0200 Subject: [PATCH 6/8] docs: scope the escape hatch to baseline validation only DisablePackageBaselineValidation suppresses the baseline download and the baseline comparison, but RunPackageValidation still executes and still performs the compatible-framework checks. The plan described it as disabling validation at pack, which both overstates it and hides a desirable property: verified that a netstandard2.0-only public type is still reported as CP0001 with the baseline disabled, so the multi-targeted packages keep their asset-compatibility gate offline. Testing that also surfaced a defect in the escape hatch. With baseline validation off the two committed baseline suppressions are never matched, and unnecessary suppressions are an error by default, so dotnet pack -p:DisablePackageBaselineValidation=true failed with "Unnecessary suppressions found" on an otherwise correct tree. The permission is now tied to the escape hatch rather than granted globally, which keeps offline work to a single property while preserving the stale-suppression detection the release handover relies on. Both directions verified: offline pack succeeds, and an ordinary pack still fails on a stale suppression entry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index 9f835d0..09bc6b8 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -13,7 +13,7 @@ Without a baseline the property is close to decorative: it activates only the co - [ ] Every packable project under `src/` builds with package validation enabled. The non-packable source generator is unaffected. - [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. - [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so compilation and test execution cost is unchanged after restore; restore itself gains the seven baseline packages, which a cold `dotnet build` or `dotnet test` pays for through implicit restore. -- [ ] A single documented property suppresses both the baseline download at restore and the validation at pack, so the solution restores, builds and tests offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. +- [ ] A single documented property suppresses the baseline download at restore and baseline validation at pack, so the solution restores, builds, tests and packs offline. Compatible-framework validation still runs offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. - [ ] The CI NuGet cache key covers the baseline packages, so they are cached rather than re-downloaded on every run, and the key rotates when the baseline version changes. - [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. - [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. @@ -48,7 +48,7 @@ The measured result against `0.6.0`, across every package and both assets, is th Both are the deliberate renumbering to 200 and 201 that reserves 16–199 for future primitive kinds. Only `netstandard2.0` entries are required; the `net10.0` asset needs none. Generate the file with `/p:ApiCompatGenerateSuppressionFile=true` rather than hand-writing it, and treat any third entry appearing during implementation as a finding to escalate, not to suppress. -Unnecessary suppressions fail the build by default, which is load-bearing for the handover below: once the baseline moves to `0.7.0`, these two entries become stale and the build fails until they are deleted. The cleanup cannot be forgotten silently, so do not set `ApiCompatPermitUnnecessarySuppressions`. +Unnecessary suppressions fail the build by default, which is load-bearing for the handover below: once the baseline moves to `0.7.0`, these two entries become stale and the build fails until they are deleted. The cleanup cannot be forgotten silently, so do not grant `ApiCompatPermitUnnecessarySuppressions` unconditionally. The one narrow exception, scoped to the offline escape hatch, is described below. Confirm the gate is real before trusting a clean run. A `0.4.0` baseline for `Light.PortableResults.AspNetCore.MinimalApis` correctly reports `CP0001` for `PortableResultsEndpointExtensions`, the type extracted into the OpenApi package in 0.5.0; that check, or an equivalent injected regression, satisfies the proof criterion. @@ -123,7 +123,16 @@ The same `ItemGroup` is conditioned on `DisablePackageBaselineValidation`, so on dotnet build ./Light.PortableResults.slnx -c Release -p:DisablePackageBaselineValidation=true ``` -Passed as a global property it suppresses the download at restore as well as the baseline check at pack. Verified with the baseline packages purged from the cache: restore fetches nothing, and build and the full test suite still pass. This belongs in the `AGENTS.md` note as the offline escape hatch, worded so it is not mistaken for a way to silence a genuine break. +Passed as a global property it suppresses the download at restore and the baseline comparison at pack. It does *not* disable package validation: `RunPackageValidation` still executes and still performs the compatible-framework checks, which is the desirable outcome — a `netstandard2.0`-only public type is reported as `CP0001` with the baseline disabled, so the multi-targeted packages keep their asset-compatibility gate offline. Describe it as disabling baseline validation, never as disabling validation. Verified with the baseline packages purged from the cache: restore fetches nothing, and build and the full test suite still pass. + +One interaction has to be handled or the escape hatch fails at pack. With baseline validation off, the two committed baseline suppressions are never matched, and unnecessary suppressions are an error by default, so `dotnet pack -p:DisablePackageBaselineValidation=true` fails with `Unnecessary suppressions found` — an offline developer would see a hard error on an otherwise correct tree. Tie the permission to the escape hatch rather than granting it globally: + +```xml + +true +``` + +Verified both directions: offline pack then succeeds with the one property, and an ordinary pack still fails on a stale suppression entry. This belongs in the `AGENTS.md` note as the offline escape hatch, worded so it is not mistaken for a way to silence a genuine break. Lock files are not involved. The baseline `PackageDownload` does not appear in `packages.lock.json` even after `dotnet restore --force`, and locked-mode restore under `ContinuousIntegrationBuild=true` succeeds unchanged — so there is no lock file maintenance and no `NU1004` risk. Determinism does not depend on the lock file either, because the injected download pins an exact version range. A lock file diff appearing during implementation means something else moved. From 396beb593a12452051acf53d6ebf8a65a3c14845 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:30:20 +0200 Subject: [PATCH 7/8] docs: condense the plan and restore four load-bearing rationales The plan is roughly half its previous length. The condensation is a net improvement, and it corrects one claim of mine: 0.6.0 ships only a netstandard2.0 asset, so the net10.0 asset genuinely has no baseline counterpart, which is why both suppression entries name netstandard2.0. Four rationales were restored because each explains a decision that would otherwise be re-litigated or quietly undone: - Why the baseline is set before 0.7.0 rather than after. Otherwise the release with the largest breaking-change surface is the only one that never gets audited. - Why the local loop is a design constraint. A check living only in CI is one that agents and developers discover after pushing. - Why the signing properties are defaulted in the props file instead of passed on the command line. A gate reachable only through an invocation nobody remembers is not a local gate. - What is at stake in the -getProperty:PublicSign check. It is the one place where a mistake ships strong-named but unsigned packages. Also records a trap found while testing the injected regression that criterion asks for: an injected public API without XML doc comments fails Release with CS1591 before validation runs, and a grep for CP diagnostics hides that failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J --- ai-plans/0082-enable-package-validation.md | 124 +++++++-------------- 1 file changed, 41 insertions(+), 83 deletions(-) diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index 09bc6b8..d5e161a 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -2,40 +2,34 @@ ## Rationale -`EnablePackageValidation` is set only on `Light.PortableResults`, and no `PackageValidationBaselineVersion` is configured anywhere. Package validation hangs off the `Pack` target, and `build-and-test.yml` never packs — the only workflow that does is `release-on-nuget.yml`, which fires on `release: published`. A shape break would therefore surface in the publish job of an already-tagged release, never during review. +Only `Light.PortableResults` enables package validation, no package has a baseline, and validation currently runs only when `release-on-nuget.yml` packs an already-tagged release. An API shape break therefore reaches review only as prose and reaches automation too late. -The gate should also run locally with no arguments. A check that exists only in CI is a check that AI coding agents and developers discover after pushing; one that a bare `dotnet pack` reproduces is a feedback loop they can close before committing. That constraint shapes the design below more than the CI job does. - -Without a baseline the property is close to decorative: it activates only the compatible-framework check, which has teeth for the two multi-targeted packages and is a no-op for the other five. The baseline check is the part that earns its keep, and 0.7.0 is the release that most needs it — the core package's release notes carry roughly twenty-five breaking-change bullets, and nothing today distinguishes the ones that break a 0.6.0 consumer from the ones that only break someone tracking `main`. Measured against a 0.6.0 baseline, the entire solution produces exactly two consumer-visible breaks. Turning the baseline on before the release converts that distinction from prose into a reviewed, enforced artifact; turning it on afterwards means the release with the largest breaking-change surface is the one release that never gets audited. +Enable validation for every published package against `0.6.0`, run it on push and pull request, and keep a bare `dotnet pack` as the exact local gate. A check that lives only in CI is one that agents and developers discover after pushing, so the local loop is a design constraint here, not a convenience. Without a baseline, only the compatible-framework check runs—useful for the two multi-targeted packages and a no-op for the other five. This matters particularly for 0.7.0: roughly twenty-five release-note bullets describe breaks, but ApiCompat shows that only two affect a 0.6.0 consumer. Baseline now rather than after the release, or the version carrying the largest breaking-change surface becomes the only one never audited. ## Acceptance Criteria -- [ ] Every packable project under `src/` builds with package validation enabled. The non-packable source generator is unaffected. -- [ ] All seven published packages validate against a `0.6.0` baseline, and an undeclared shape break fails the build. -- [ ] `dotnet pack -c Release` reproduces the CI gate exactly, with no additional arguments, properties or scripts. No validation runs during `dotnet build` or `dotnet test`, so compilation and test execution cost is unchanged after restore; restore itself gains the seven baseline packages, which a cold `dotnet build` or `dotnet test` pays for through implicit restore. -- [ ] A single documented property suppresses the baseline download at restore and baseline validation at pack, so the solution restores, builds, tests and packs offline. Compatible-framework validation still runs offline. `packages.lock.json` files stay unchanged and locked-mode restore keeps working. -- [ ] The CI NuGet cache key covers the baseline packages, so they are cached rather than re-downloaded on every run, and the key rotates when the baseline version changes. -- [ ] Package validation runs on push and pull request, so a break is caught in review rather than during the release job. -- [ ] Validation compares like with like on strong naming: a build without the release SNK produces no `CP0003`, and the release job keeps producing genuinely signed assemblies. Packages built for validation are never pushed. -- [ ] Strong-naming the `src` assemblies by default leaves the test suite and the Native AOT sample publish green. -- [ ] Packing the solution emits no warning for the non-packable sample, in the release workflow as well as locally. -- [ ] `AGENTS.md` documents the local command, how to tell success from failure, and the offline escape hatch. -- [ ] The only committed suppressions are the two `MetadataKind` enum-value changes already named in the release notes. Every other break listed there is confirmed to be either behavioral or invisible to a 0.6.0 consumer. -- [ ] The gate is proven with an injected regression: removing or renaming a public member fails with the corresponding `CP` diagnostic and a non-zero exit code, on an incremental pack as well as a clean one. -- [ ] The post-release step — bump the baseline to `0.7.0` and delete the suppression file — is recorded where it will be found at release time. +- [ ] All seven packable projects under `src/` validate against `0.6.0`; the non-packable source generator does not run package validation, and an undeclared shape break fails the build. +- [ ] `dotnet pack -c Release` reproduces the CI gate without extra arguments, properties or scripts. Validation does not run during build or test; only restore gains the seven baseline packages. +- [ ] One documented property disables baseline acquisition and comparison so restore, build, test and pack work offline. Compatible-framework validation remains active, lock files remain unchanged, and locked restore succeeds. +- [ ] Validation runs on push and pull request. The CI NuGet cache includes baseline inputs and rotates when the baseline version changes; validation packages are never pushed. +- [ ] Ordinary builds use the matching public signing key and produce no `CP0003`; releases remain genuinely private-signed. Strong-naming all `src` assemblies leaves the full test suite and Native AOT sample publish green. +- [ ] Solution packing emits no non-packable-project warning locally or during release. +- [ ] `AGENTS.md` documents the local command, exit-code/diagnostic interpretation, restore impact, and offline escape hatch. +- [ ] The only suppressions are the two intentional `MetadataKind` value changes already in the release notes; every other listed break is confirmed behavioral or absent from the 0.6.0 API. +- [ ] An injected public-member regression fails clean and incremental packs with the corresponding `CP` diagnostic and a non-zero exit code. +- [ ] The release-time handover to a `0.7.0` baseline with no suppression file is recorded where it will be found. ## Technical Details -### What the baseline does and does not catch +### Baseline and suppressions -ApiCompat compares assembly shape. Most of the 0.7.0 breaking-change bullets are behavioral — decimal serializing as a JSON number, header quoting, null CloudEvents attributes being omitted, the new default-instance guard throwing — and none of them will ever produce a diagnostic. The prose release notes stay load-bearing; validation makes only the shape subset enforceable. +ApiCompat compares assembly shape, not behavior. Changes such as JSON number encoding, header quoting, omitted null CloudEvents attributes and the default-result guard remain enforced only by tests and release notes. Other apparent breaks concern APIs that never shipped in 0.6.0, including `CanonicalFloatingPointFormatter`, `PortableResultsJsonContracts`, `MustNotBeDefaultInstance` and `HttpHeaderValueFormatter`. -A large share of the remaining bullets describe breaks against intermediate states on `main` rather than against 0.6.0. `CanonicalFloatingPointFormatter`, `PortableResultsJsonContracts`, `MustNotBeDefaultInstance` and `HttpHeaderValueFormatter` were never in the published 0.6.0 assembly, so their removal or renaming cannot break a consumer. This is why the measured break count is two rather than twenty-five, and it is worth stating in the release notes review rather than treating the low number as a tooling failure. +All seven packages published 0.6.0; `AspNetCore.OpenApi` and `Validation.OpenApi` first shipping in 0.5.0 does not affect that baseline. A single `PackageValidationBaselineVersion` in `src/Directory.Build.props` therefore covers every packable source project. -The measured result against `0.6.0`, across every package and both assets, is the complete suppression file at `src/Light.PortableResults/CompatibilitySuppressions.xml`: +The complete measured break set is `CP0011` for `MetadataKind.Array` and `MetadataKind.Object`, whose values intentionally moved from 5/6 to 200/201 to reserve 16–199 for future primitive kinds. Only the `netstandard2.0` asset needs suppressions; `net10.0` has no baseline counterpart. Generate `src/Light.PortableResults/CompatibilitySuppressions.xml` with `/p:ApiCompatGenerateSuppressionFile=true`; its two discriminating entries are: ```xml - CP0011 F:Light.PortableResults.Metadata.MetadataKind.Array @@ -43,118 +37,82 @@ The measured result against `0.6.0`, across every package and both assets, is th lib/netstandard2.0/Light.PortableResults.dll true - + ``` -Both are the deliberate renumbering to 200 and 201 that reserves 16–199 for future primitive kinds. Only `netstandard2.0` entries are required; the `net10.0` asset needs none. Generate the file with `/p:ApiCompatGenerateSuppressionFile=true` rather than hand-writing it, and treat any third entry appearing during implementation as a finding to escalate, not to suppress. - -Unnecessary suppressions fail the build by default, which is load-bearing for the handover below: once the baseline moves to `0.7.0`, these two entries become stale and the build fails until they are deleted. The cleanup cannot be forgotten silently, so do not grant `ApiCompatPermitUnnecessarySuppressions` unconditionally. The one narrow exception, scoped to the offline escape hatch, is described below. - -Confirm the gate is real before trusting a clean run. A `0.4.0` baseline for `Light.PortableResults.AspNetCore.MinimalApis` correctly reports `CP0001` for `PortableResultsEndpointExtensions`, the type extracted into the OpenApi package in 0.5.0; that check, or an equivalent injected regression, satisfies the proof criterion. +Treat any third generated entry as a finding, not another suppression. Unnecessary suppressions must fail ordinary builds so the post-release baseline bump forces deletion of this file; permit them only for the offline escape hatch described below. -All seven packages published a `0.6.0`, so a single `PackageValidationBaselineVersion` in `src/Directory.Build.props` covers them. `AspNetCore.OpenApi` and `Validation.OpenApi` first shipped in 0.5.0, which does not affect a 0.6.0 baseline. Setting the baseline has restore-time consequences, covered under the local loop below. +Prove the gate with an injected regression. Changing `MetadataKind.String` from 4 is suitable because the 0.6.0 enum contains only `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array` and `Object`. An alternative is a 0.4.0 baseline for `Light.PortableResults.AspNetCore.MinimalApis`, which reports `CP0001` for the later-extracted `PortableResultsEndpointExtensions`. Verify both a clean pack and an already-packed incremental tree. Give any injected public API XML doc comments: `TreatWarningsAsErrors` turns `CS1591` into a Release build error, and a grep for `CP` diagnostics hides that failure, so the probe reads as validation being broken. ### Strong naming -This is the blocker. Published packages are strong-named through an SNK that exists only as a GitHub Actions secret, so ordinary builds are unsigned and every package fails the baseline on identity alone: - -``` -CP0003: [Baseline] ... assembly public key token 'cc46d8340219f3bd' does not match ... 'null' -``` - -`Light.PortableResults.Public.snk` is already committed at the repository root and carries the matching token. Do not suppress `CP0003` instead — that would hide a real signing regression at the one point where it is observable. - -Passing the signing properties on the command line would work, but it puts the gate behind an invocation nobody remembers, which defeats the local-loop goal. Default them in `src/Directory.Build.props` instead, keyed off whether a key file was supplied: +The 0.6.0 packages are strong-named, while ordinary builds are unsigned, causing `CP0003` for token `cc46d8340219f3bd` versus `null`. `Light.PortableResults.Public.snk` contains the matching public key. Do not suppress this diagnostic; default ordinary `src` builds to public signing instead. Default it in the props file rather than passing the properties on the command line: a gate reachable only through an invocation nobody remembers is not a local gate. ```xml - true true true $(MSBuildThisFileDirectory)../Light.PortableResults.Public.snk ``` -The release workflow passes `AssemblyOriginatorKeyFile` as a global property, which cannot be overridden from a props file, so `UsePublicSigningKey` stays empty there and `PublicSign` is never set — the release build signs with the private key exactly as it does today. Verify this with `-getProperty:PublicSign` under both invocations rather than by inspection; it is the one place where a mistake ships unsigned-but-strong-named packages to NuGet. Do not route the guard through `SignAssembly`: the release workflow sets that too, so it cannot distinguish the two cases. +The guard reads rather than sets `AssemblyOriginatorKeyFile`, so snippet order is irrelevant. The release workflow supplies that property globally together with `SignAssembly`; the props file cannot override it, `UsePublicSigningKey` stays empty, and `PublicSign` remains unset. Verify `PublicSign` with `-getProperty` for both invocations rather than by inspection: this is the one place where a mistake ships strong-named but unsigned packages to NuGet. Do not guard on `SignAssembly`, which the release also sets. -Use `$(MSBuildThisFileDirectory)` rather than the `../../` that `release-on-nuget.yml` uses. The relative form resolves against the project file and only works because every packable project sits exactly two levels down. +Use `$(MSBuildThisFileDirectory)` for the committed key. The release workflow's `../../` resolves relative to each project and works only because all packable projects are two levels below the root. -The consequence is that all `src` assemblies become strong-named in every build, not just when packing. That is a real change and the reason the acceptance criteria call for the test suite and the AOT sample: public-signed assemblies carry the strong name without a valid signature, which .NET Core does not verify but which would break `InternalsVisibleTo` if the solution used it — it does not. It is also what lets CI validate with a `--no-build` pack over the existing build output, as described below. Packages produced outside the release workflow must never be pushed. +All `src` assemblies consequently become strong-named in ordinary builds. Public signing carries the strong-name identity without a valid private signature; .NET Core accepts it, but it would affect `InternalsVisibleTo`, which this solution does not use. Run the full suite and Native AOT publish, and never push packages produced outside the private-signing release workflow. -### CI shape and the local loop +### Local, offline and CI execution -Validation *runs* on `Pack`, so no `CP` diagnostic can appear during `dotnet build` or `dotnet test` and their cost is unchanged. Agents and developers opt in with: +The local gate is: ```shell dotnet pack ./Light.PortableResults.slnx -c Release ``` -No properties, no script, no separate target — the same command CI runs, and on a warm NuGet cache it completes in a few seconds. Document it in `AGENTS.md` next to the existing build and test guidance. - -Describe success by exit code and the absence of `CP` diagnostics, never by asserting expected output. Pack is incremental, so the console is not a reliable success signal: a first run prints restore lines, build lines and `Successfully created package` lines, a second run with no source changes prints build lines and no package lines at all, and a `--no-build` repeat on an up-to-date tree prints *nothing whatsoever* and exits 0. An agent told to look for `Successfully created package` would read that silent, correct run as a failure. Measured, on a clean tree: `--no-build` produces zero output lines and exit 0; with a break present, exit 1 and `error CP0011: ...` naming the API. - -Incremental packing does not weaken the gate. Verified by changing `MetadataKind.String` from `4` — a member that exists in the 0.6.0 baseline — on an already-packed, up-to-date tree: the break is reported on the next `dotnet pack` and on the next `--no-build` pack. Note when constructing such a check that most `MetadataKind` members are new in 0.7.0, so altering them proves nothing; the baseline only contains `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array` and `Object`. - -Remove the non-packable warning rather than documenting around it. Packing the solution today prints: - -``` -warning : This project cannot be packaged because packaging has been disabled. ... - [samples/NativeAotMovieRating/NativeAotMovieRating.csproj] -``` - -`Microsoft.NET.Sdk.Web.ProjectSystem.props` sets `WarnOnPackingNonPackableProject` to `true` for web projects, which the sample is; the other non-packable projects use non-web SDKs and stay quiet. Setting the property to `false` in the sample silences it. This is worth fixing on its own account — the warning is in the release workflow's pack output today — and it is what leaves a correct `--no-build` run genuinely silent instead of showing a lone warning as its only output. - -Baseline *acquisition* is a different matter and does reach the ordinary loop. The SDK injects the `PackageDownload` from an evaluation-time `ItemGroup` in `Microsoft.NET.ApiCompat.targets`, not from a target, so it participates in restore whether or not `Pack` ever runs. A plain `dotnet restore` — and therefore `dotnet build` or `dotnet test` with implicit restore — fetches all seven 0.6.0 packages. Do not describe the inner loop as untouched; the accurate statement is that it is untouched *after restore*. +Judge success by exit code and absence of `CP` diagnostics, not console text: incremental runs may print package lines, build lines, or nothing. Confirm an injected break returns non-zero and names the API. `--no-build` still runs `RunPackageValidation` over previously built outputs. -That cost is one cold download of seven small packages, cached thereafter, so it does not justify a redesign. Gating the baseline on whether packing is underway would mean depending on the undocumented `_IsPacking` property and would leave the CI restore without the baselines that the `--no-build` pack step needs. Accept the download and document the opt-out instead. +Solution packing currently warns because the Native AOT web sample is non-packable and `Microsoft.NET.Sdk.Web.ProjectSystem.props` defaults `WarnOnPackingNonPackableProject` to `true`. Set it to `false` in the sample; other non-packable projects already remain quiet. -The existing CI cache does not cover it without a change, and this is not self-correcting. `.github/actions/cache-nuget/action.yml` keys on `hashFiles('**/packages.lock.json')`, and the baseline `PackageDownload` provably does not alter those files, so the key is unchanged by this work. `actions/cache` skips its post-job save on an exact key hit, so the pre-existing entry — which predates the baseline and does not contain the seven packages — would be restored on every run, the baselines re-downloaded every time, and the entry never refreshed. This affects every run, not only fresh runners, and it applies to `release-on-nuget.yml` too, which uses the same composite action. +Baseline acquisition occurs during every restore, not only pack: `Microsoft.NET.ApiCompat.targets` adds an evaluation-time `PackageDownload`. Thus implicit restore in `dotnet build` or `dotnet test` pays one cold download of seven small packages, while compilation and test execution after restore are unchanged. Avoid conditioning on the undocumented `_IsPacking`; CI needs the baseline restored before its `--no-build` pack. -Add the file that declares the baseline to the key: +`PackageDownload` does not update `packages.lock.json`, so the existing cache key would not rotate and an immutable exact cache hit could never acquire the new packages. Include the baseline declaration in the key: ```yaml key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json', 'src/Directory.Build.props') }} ``` -That is the correct dependency set rather than a workaround: lock files describe what `PackageReference` resolves to, and `src/Directory.Build.props` is where `PackageValidationBaselineVersion` lives, so together they cover everything restore fetches. It rotates the key once when this change lands, so the refreshed entry is saved with the baselines in it, and rotates again when the baseline moves to 0.7.0 at release. Do not hard-code the baseline version into the cache key instead: it would duplicate a value that already exists in the props file and would silently go stale the first time someone bumps one without the other. The occasional unnecessary cache rebuild when that props file changes for an unrelated reason is the accepted cost. +This rotates when the baseline changes without duplicating its version in the workflow; unrelated edits to the props file may rebuild the cache. The shared composite action covers both build and release workflows. -The same `ItemGroup` is conditioned on `DisablePackageBaselineValidation`, so one property covers both halves: +The offline escape hatch is `-p:DisablePackageBaselineValidation=true`, for example: ```shell dotnet build ./Light.PortableResults.slnx -c Release -p:DisablePackageBaselineValidation=true ``` -Passed as a global property it suppresses the download at restore and the baseline comparison at pack. It does *not* disable package validation: `RunPackageValidation` still executes and still performs the compatible-framework checks, which is the desirable outcome — a `netstandard2.0`-only public type is reported as `CP0001` with the baseline disabled, so the multi-targeted packages keep their asset-compatibility gate offline. Describe it as disabling baseline validation, never as disabling validation. Verified with the baseline packages purged from the cache: restore fetches nothing, and build and the full test suite still pass. - -One interaction has to be handled or the escape hatch fails at pack. With baseline validation off, the two committed baseline suppressions are never matched, and unnecessary suppressions are an error by default, so `dotnet pack -p:DisablePackageBaselineValidation=true` fails with `Unnecessary suppressions found` — an offline developer would see a hard error on an otherwise correct tree. Tie the permission to the escape hatch rather than granting it globally: +It suppresses the baseline download and comparison but leaves compatible-framework validation active. Because the two baseline suppressions then become unmatched and normally fail as unnecessary, scope the exception to this property: ```xml - true ``` -Verified both directions: offline pack then succeeds with the one property, and an ordinary pack still fails on a stale suppression entry. This belongs in the `AGENTS.md` note as the offline escape hatch, worded so it is not mistaken for a way to silence a genuine break. - -Lock files are not involved. The baseline `PackageDownload` does not appear in `packages.lock.json` even after `dotnet restore --force`, and locked-mode restore under `ContinuousIntegrationBuild=true` succeeds unchanged — so there is no lock file maintenance and no `NU1004` risk. Determinism does not depend on the lock file either, because the injected download pins an exact version range. A lock file diff appearing during implementation means something else moved. +Verify with baseline packages absent that restore downloads none and build, tests and pack pass; verify ordinary pack still rejects stale suppressions. Locked restore under `ContinuousIntegrationBuild=true` must remain green with no lock-file diff or `NU1004`; the exact `PackageDownload` version preserves determinism. -In `build-and-test.yml` this is a step, not a job. Because signing is defaulted in the props file, the existing build job already produces the strong-named assemblies validation needs, so a `--no-build` pack reuses them and adds a couple of seconds: +Add this step to `build-and-test.yml` after tests so a validation failure does not mask a test failure: ```shell dotnet pack ./Light.PortableResults.slnx --configuration Release --no-build /p:ContinuousIntegrationBuild=true ``` -Verified: `--no-build` still runs `RunPackageValidation`. Nothing here warrants the separate-job treatment `native-aot-publish` gets — that split exists because native compilation is slow, which does not apply. Put the step after the test run so a validation failure does not mask a test failure. The packages it produces are a byproduct and must not be pushed. +It reuses the public-signed Release outputs, still executes package validation, and produces unpushed package byproducts. A separate job is unnecessary; unlike Native AOT publication, validation is fast. ### Release handover -After 0.7.0 is tagged, `PackageValidationBaselineVersion` moves to `0.7.0` and `src/Light.PortableResults/CompatibilitySuppressions.xml` is deleted in the same commit, so the 0.8.0 cycle starts from a clean baseline with no suppressions. The repository has no release checklist document today, so record this as a follow-up issue on #77 rather than inventing one as part of this change. +After 0.7.0 is tagged, change `PackageValidationBaselineVersion` to `0.7.0` and delete `src/Light.PortableResults/CompatibilitySuppressions.xml` in the same commit, starting the 0.8.0 cycle from a clean baseline. Record this as a follow-up issue on #77 because the repository has no release checklist. ### Deliberately out of scope -- Package validation for `Light.PortableResults.Validation.OpenApi.SourceGeneration`. It sets `IsPackable=false`, so validation never runs for it and nothing needs to opt it out. -- Runtime-specific asset validation. No package ships RID-specific assets. -- Reverting or reworking the `MetadataKind` renumbering. It is intentional and already documented; the suppression records it. -- Any change to the release signing model. Ordinary builds do gain public signing, but the private key, the workflow and the published artifacts are untouched. -- A dedicated MSBuild target, script or `dotnet` tool wrapping the local check. `dotnet pack -c Release` is the whole interface. -- Making the sample packable, or removing it from the solution. Only its warning is silenced. -- Making behavioral breaks enforceable. ApiCompat cannot see them and the release notes remain the record. -- The remaining v0.7.0 preparations. This is item 3 of #77; Native AOT compatibility is #78 and the default-result write guard is #80. +- Validation for `Light.PortableResults.Validation.OpenApi.SourceGeneration`, which is non-packable. +- RID-specific asset validation; no package ships RID-specific assets. +- Reworking the intentional `MetadataKind` renumbering or enforcing behavioral breaks through ApiCompat. +- Changing private release signing, making the sample packable, removing it from the solution, or wrapping pack in a custom target, script or tool. +- Other 0.7.0 preparation: this is item 3 of #77; Native AOT compatibility is #78 and the default-result write guard is #80. From a12d998f8f655c61f57473c2265f8fe0d150ec7e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 4 Aug 2026 07:55:17 +0200 Subject: [PATCH 8/8] build: enable package validation across all packages All seven packable projects under src/ now validate against their published 0.6.0 package, so an undeclared change to the public API shape fails the build instead of reaching review as prose. Validation previously ran only on Light.PortableResults, without a baseline, and only when release-on-nuget.yml packed an already-tagged release. The gate is a bare `dotnet pack ./Light.PortableResults.slnx -c Release` with no extra arguments, properties or scripts, and CI runs the same thing with --no-build after the tests so a shape break cannot mask a test failure. Three things about the MSBuild setup are worth recording: - IsPackable has to be set explicitly in src/Directory.Build.props. Microsoft.NET.ApiCompat.targets is imported before the NuGet pack targets that default it to true, and the baseline PackageDownload is an evaluation-time item, so leaving IsPackable implicit silently acquires no baseline and validates nothing. Verified per project via -getItem:PackageDownload: seven declare [0.6.0], the non-packable source generator declares none. - Ordinary builds are public-signed with the committed public key, because the 0.6.0 packages are strong-named and unsigned builds would report CP0003 against every baseline. The guard reads AssemblyOriginatorKeyFile rather than SignAssembly, which the release also sets. Verified with -getProperty for both invocation shapes: ordinary builds get PublicSign=true, and the release invocation leaves both UsePublicSigningKey and PublicSign empty, so released packages stay genuinely private-signed. - The CI cache key now also hashes src/Directory.Build.props. PackageDownload never writes to packages.lock.json, so a lock-file-only key would not rotate on a baseline bump and an exact hit on the immutable cache could never acquire the new packages. The suppression file holds exactly the two intentional MetadataKind renumberings the release notes already describe; the generator produced no third entry. Unnecessary suppressions remain an error, which is what forces the file to be deleted when the baseline moves to 0.7.0. Verified beyond the criteria: an injected MetadataKind.String change fails clean packs, incremental packs, and the CI build-then-no-build- pack sequence, each with CP0011 and a non-zero exit; a stale suppression fails an ordinary pack; with all seven baselines removed from the NuGet cache, restore/build/test/pack succeed under -p:DisablePackageBaselineValidation=true without downloading any of them, while a netstandard2.0-only type still fails with CP0001, so the compatible-framework gate survives the escape hatch; locked restore is green with no lock-file diff; the suite is 2791/2791 and the Native AOT publish is clean under strong naming. The plan's suggested probe value does not compile - MetadataKind.String = 15 collides with an existing kind and yields CS0152 and CS8510 instead of a CP diagnostic, which is the masked-failure mode the plan warns about one sentence later. Recorded as a correction on the plan. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HQQpp9JcvNV1N8xfPpiWq9 --- .github/actions/cache-nuget/action.yml | 7 +++- .github/workflows/build-and-test.yml | 10 ++++++ AGENTS.md | 36 +++++++++++++++++++ ai-plans/0082-enable-package-validation.md | 22 ++++++------ .../NativeAotMovieRating.csproj | 2 ++ src/Directory.Build.props | 34 ++++++++++++++++++ .../CompatibilitySuppressions.xml | 18 ++++++++++ .../Light.PortableResults.csproj | 1 - 8 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/Light.PortableResults/CompatibilitySuppressions.xml diff --git a/.github/actions/cache-nuget/action.yml b/.github/actions/cache-nuget/action.yml index 01b2aea..6f7b0ed 100644 --- a/.github/actions/cache-nuget/action.yml +++ b/.github/actions/cache-nuget/action.yml @@ -5,10 +5,15 @@ author: 'Kenny Pflug' runs: using: 'composite' steps: + # src/Directory.Build.props is part of the key because it declares PackageValidationBaselineVersion. + # The baselines are acquired through PackageDownload, which never touches packages.lock.json, so a + # lock-file-only key would not rotate when the baseline version changes and an exact hit on the + # immutable cache could never acquire the new baseline packages. Unrelated edits to that props file + # rebuild the cache, which is cheaper than duplicating the baseline version in this workflow. - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json', 'src/Directory.Build.props') }} restore-keys: | nuget-${{ runner.os }}- diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 90575f5..b3dbe5e 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -61,6 +61,16 @@ jobs: --configuration Release -p:PortableResultsAssetTargetFramework=netstandard2.0 -p:ContinuousIntegrationBuild=true + # Package validation runs after the tests so that an undeclared API shape break cannot mask a test + # failure. --no-build reuses the public-signed Release outputs above and still executes + # RunPackageValidation over the packages it produces. Those packages are byproducts and are never + # pushed; only the release workflow publishes, and only from privately signed builds. + - name: Validate packages + run: > + dotnet pack ./Light.PortableResults.slnx + --configuration Release + --no-build + /p:ContinuousIntegrationBuild=true # ILC regression gate for the Native AOT compatibility claim. Trim and AOT diagnostics from the # Light.PortableResults project references are fatal in Release, while rollups from unannotated package diff --git a/AGENTS.md b/AGENTS.md index e070cf8..48b9661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,42 @@ In our Directory.Build.props files in this solution, the following rules are def - When a type or method is properly encapsulated, make it public. We don't know how callers would like to use this library. When some types are internal, this might make it hard for callers to access these in tests or when making configuration changes. Prefer public APIs over internal ones. - Use Conventional Commits messages. Decide whether a commit title is enough or a commit body is required. +## Package Validation + +Every packable project under `src/` is validated against its published `0.6.0` package, so an undeclared change to the public API shape fails the build instead of reaching review as prose. The local gate is exactly the CI gate: + +```shell +dotnet pack ./Light.PortableResults.slnx -c Release +``` + +No extra arguments, properties or scripts. Run it before pushing an API change. + +Judge the result by the exit code, not by the console text: an incremental run may print package lines, build lines, or nothing at all, because the validation target skips when its inputs are unchanged. A non-zero exit and a `CP` diagnostic naming the affected API mean a break was found: + +- `CP0001`/`CP0002` - a type or member that `0.6.0` shipped is gone, or the `net10.0` asset is missing API that `netstandard2.0` has. +- `CP0003` - the assembly identity changed, in practice the strong-name key. +- `CP0011` - an enum field changed its numeric value. + +If the break is intentional, say so in `PackageReleaseNotes` and regenerate the suppression file with `/p:ApiCompatGenerateSuppressionFile=true`. Review every generated entry: the tool suppresses whatever it finds, including the break you did not mean to make. Unnecessary suppressions fail the build, so a suppression file never silently outlives the break it covers. + +ApiCompat compares assembly shape only. Behavioral breaks - encoding changes, new guard clauses, changed exception types - are invisible to it and stay the responsibility of tests and release notes. + +The baseline packages are acquired by a `PackageDownload` that is added during evaluation, so **every** restore fetches them, not just `pack`. A cold restore therefore pays one download of seven small packages; compilation and test execution afterwards are unaffected. `PackageDownload` does not write to `packages.lock.json`, which is why the CI cache key also hashes `src/Directory.Build.props`. + +To work offline, or whenever the baseline packages cannot be reached: + +```shell +dotnet build ./Light.PortableResults.slnx -c Release -p:DisablePackageBaselineValidation=true +``` + +The property works for `restore`, `build`, `test` and `pack`. It skips the baseline download and the baseline comparison, and it permits the then-unmatched baseline suppressions. Validation against the compatible frameworks inside the package stays active, so a `net10.0` asset that loses API relative to `netstandard2.0` still fails. Do not commit work verified only this way - the baseline comparison is the part that was switched off. + +Ordinary `src` builds are public-signed with the committed `Light.PortableResults.Public.snk` so that their identity matches the strong-named `0.6.0` baselines. This is set in `src/Directory.Build.props` rather than passed on the command line, precisely so that the plain `dotnet pack` above is the whole gate. Public signing carries the strong-name identity without a valid signature, which is why packages must only ever be pushed by the release workflow: it supplies the private key and produces genuinely signed assemblies. + +### After a release + +When `0.7.0` is tagged, set `PackageValidationBaselineVersion` to `0.7.0` in `src/Directory.Build.props` and delete `src/Light.PortableResults/CompatibilitySuppressions.xml` in the same commit. That file holds only the two intentional `MetadataKind.Array`/`MetadataKind.Object` renumberings from the `0.6.0` era; against a `0.7.0` baseline they are no longer breaks, and leaving them would fail the build as unnecessary suppressions. The `0.8.0` cycle starts from a clean baseline with no suppression file. + ## Testing Rules Read ./tests/AGENTS.md for details about how to write tests. diff --git a/ai-plans/0082-enable-package-validation.md b/ai-plans/0082-enable-package-validation.md index d5e161a..47fc281 100644 --- a/ai-plans/0082-enable-package-validation.md +++ b/ai-plans/0082-enable-package-validation.md @@ -1,5 +1,7 @@ # Enable Package Validation Across All Packages +> Correction: the suggested probe value in Technical Details does not compile. Setting `MetadataKind.String` to `15` collides with an existing kind and yields `CS0152` and `CS8510` instead of a `CP` diagnostic — exactly the masked-failure mode the following sentence warns about, reached through a value collision rather than through `CS1591`. Pick a value inside the reserved 16–199 range; `150` was used to verify the gate. The rest of the probe reasoning holds: the 0.6.0 enum does contain `String`, so changing its value reports `CP0011` on the `netstandard2.0` asset. + ## Rationale Only `Light.PortableResults` enables package validation, no package has a baseline, and validation currently runs only when `release-on-nuget.yml` packs an already-tagged release. An API shape break therefore reaches review only as prose and reaches automation too late. @@ -8,16 +10,16 @@ Enable validation for every published package against `0.6.0`, run it on push an ## Acceptance Criteria -- [ ] All seven packable projects under `src/` validate against `0.6.0`; the non-packable source generator does not run package validation, and an undeclared shape break fails the build. -- [ ] `dotnet pack -c Release` reproduces the CI gate without extra arguments, properties or scripts. Validation does not run during build or test; only restore gains the seven baseline packages. -- [ ] One documented property disables baseline acquisition and comparison so restore, build, test and pack work offline. Compatible-framework validation remains active, lock files remain unchanged, and locked restore succeeds. -- [ ] Validation runs on push and pull request. The CI NuGet cache includes baseline inputs and rotates when the baseline version changes; validation packages are never pushed. -- [ ] Ordinary builds use the matching public signing key and produce no `CP0003`; releases remain genuinely private-signed. Strong-naming all `src` assemblies leaves the full test suite and Native AOT sample publish green. -- [ ] Solution packing emits no non-packable-project warning locally or during release. -- [ ] `AGENTS.md` documents the local command, exit-code/diagnostic interpretation, restore impact, and offline escape hatch. -- [ ] The only suppressions are the two intentional `MetadataKind` value changes already in the release notes; every other listed break is confirmed behavioral or absent from the 0.6.0 API. -- [ ] An injected public-member regression fails clean and incremental packs with the corresponding `CP` diagnostic and a non-zero exit code. -- [ ] The release-time handover to a `0.7.0` baseline with no suppression file is recorded where it will be found. +- [x] All seven packable projects under `src/` validate against `0.6.0`; the non-packable source generator does not run package validation, and an undeclared shape break fails the build. +- [x] `dotnet pack -c Release` reproduces the CI gate without extra arguments, properties or scripts. Validation does not run during build or test; only restore gains the seven baseline packages. +- [x] One documented property disables baseline acquisition and comparison so restore, build, test and pack work offline. Compatible-framework validation remains active, lock files remain unchanged, and locked restore succeeds. +- [x] Validation runs on push and pull request. The CI NuGet cache includes baseline inputs and rotates when the baseline version changes; validation packages are never pushed. +- [x] Ordinary builds use the matching public signing key and produce no `CP0003`; releases remain genuinely private-signed. Strong-naming all `src` assemblies leaves the full test suite and Native AOT sample publish green. +- [x] Solution packing emits no non-packable-project warning locally or during release. +- [x] `AGENTS.md` documents the local command, exit-code/diagnostic interpretation, restore impact, and offline escape hatch. +- [x] The only suppressions are the two intentional `MetadataKind` value changes already in the release notes; every other listed break is confirmed behavioral or absent from the 0.6.0 API. +- [x] An injected public-member regression fails clean and incremental packs with the corresponding `CP` diagnostic and a non-zero exit code. +- [x] The release-time handover to a `0.7.0` baseline with no suppression file is recorded where it will be found. ## Technical Details diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index 01aa9cd..781cbba 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -4,6 +4,8 @@ true true false + + false $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + true + true + 0.6.0 + true + + + + + true + true + true + $(MSBuildThisFileDirectory)../Light.PortableResults.Public.snk + + diff --git a/src/Light.PortableResults/CompatibilitySuppressions.xml b/src/Light.PortableResults/CompatibilitySuppressions.xml new file mode 100644 index 0000000..e3ef11b --- /dev/null +++ b/src/Light.PortableResults/CompatibilitySuppressions.xml @@ -0,0 +1,18 @@ + + + + + CP0011 + F:Light.PortableResults.Metadata.MetadataKind.Array + lib/netstandard2.0/Light.PortableResults.dll + lib/netstandard2.0/Light.PortableResults.dll + true + + + CP0011 + F:Light.PortableResults.Metadata.MetadataKind.Object + lib/netstandard2.0/Light.PortableResults.dll + lib/netstandard2.0/Light.PortableResults.dll + true + + diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index e8f1992..781cfc7 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -5,7 +5,6 @@ false true true - true The Light.PortableResults package implements the core functionality: Results, Errors, Metadata, Functional Extensions, and serialization support for various formats like HTTP and CloudEvents. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc. Light.PortableResults 0.7.0