Skip to content

Enable package validation - #83

Merged
feO2x merged 8 commits into
mainfrom
82-enable-package-validation
Aug 4, 2026
Merged

Enable package validation#83
feO2x merged 8 commits into
mainfrom
82-enable-package-validation

Conversation

@feO2x

@feO2x feO2x commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #82

feO2x and others added 8 commits August 4, 2026 06:44
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Hxg7Ci3BXPMS8Q8K9J49J
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQQpp9JcvNV1N8xfPpiWq9
@feO2x feO2x self-assigned this Aug 4, 2026
@feO2x feO2x added enhancement New feature or request chore Internal cleanup work labels Aug 4, 2026
@feO2x feO2x linked an issue Aug 4, 2026 that may be closed by this pull request
10 tasks
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Light.PortableResults 98% 95% 3567
Light.PortableResults.AspNetCore.MinimalApis 100% 100% 33
Light.PortableResults.AspNetCore.Mvc 100% 100% 33
Light.PortableResults.AspNetCore.OpenApi 94% 83% 505
Light.PortableResults.AspNetCore.Shared 100% 100% 28
Light.PortableResults.Validation 97% 89% 3050
Light.PortableResults.Validation.OpenApi 98% 91% 146
Light.PortableResults.Validation.OpenApi.SourceGeneration 92% 86% 1164
Summary 97% (15665 / 16233) 91% (6640 / 7304) 8526

Minimum allowed line rate is 60%

@feO2x
feO2x merged commit afdffc9 into main Aug 4, 2026
3 checks passed
@feO2x
feO2x deleted the 82-enable-package-validation branch August 4, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore Internal cleanup work enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable package validation across all packages

1 participant