Skip to content

4.1.0: one package to install, two folded into core, and the security findings - #48

Merged
ivanvyd merged 7 commits into
mainfrom
feature/core-packaging
Jul 30, 2026
Merged

4.1.0: one package to install, two folded into core, and the security findings#48
ivanvyd merged 7 commits into
mainfrom
feature/core-packaging

Conversation

@ivanvyd

@ivanvyd ivanvyd commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Twenty packages was too many, and two of them had no business being separate. This fixes that without breaking anything — an application on 4.0.0 upgrades by changing nothing.

Packaging

  • Healthie.NET is new and is the package to install. It carries no code, only a dependency on Healthie.NET.DependencyInjection. Deliberately not a bundle: verified from the packed .nuspec that it declares exactly one dependency and ships zero lib/ assets, so no provider, scheduler or UI framework arrives with it.
  • Uptime and leader election moved into core. Both carried zero third-party dependencies, so splitting them out bought a consumer nothing and cost them two installs. The provider packages exist to keep Npgsql or Temporalio off machines that don't use them; these two were not providers.
  • Healthie.NET.Uptime and Healthie.NET.LeaderElection are deprecated but still work. They ship as assemblies of type forwards, so an application referencing either keeps compiling and keeps running untouched. That is what makes this a minor release rather than a major: moving a type between assemblies is a binary break the compiler cannot see, because source keeps building and only the runtime finds out.

Why not 5.0.0, and why not a rewrite of 4.0.0: nuget.org versions are immutable, so 4.0.0 cannot be republished. Making the fold non-breaking removes the need for a major at all.

Security

  • CosmosDB key from May 2025 confirmed dead, alert closed as revoked. The account no longer exists in the owning tenant — verified by enumerating every Cosmos account across its subscriptions, with a positive control proving the credential could see resources. Key remains in history at ee0c78e, inert.
  • Four Scorecard Pinned-Dependencies findings fixed — both sample Dockerfiles pinned by digest, and the pinned build verified to still succeed.
  • 44 stray screenshots deleted from the repo root (admin UIs, an email preview, from unrelated local work) and root PNGs gitignored. Scoped to the root, with both directions asserted so docs/assets, the banner and the sample favicon stay trackable.

Docs

README package section retiered from a flat 20-row table into what you are actually deciding — swap the storage, swap the scheduler, add a surface, add a capability — with the one-line install and its dependency chain up front. Plus a docs/migration-v3-to-v4.md.

Tests

New: type-forward resolution for all 13 moved types (asserted against assembly-qualified names, since C# binding is exactly what hides a missing forward), a guard that the meta-package cannot become a bundle, alert-history durability against real Redis and PostgreSQL, and the metrics collector, which had none.

Verification

  • Release build: 0 errors, 0 warnings
  • Unit: 557/557 on net8.0 and net10.0, stable across three consecutive full runs
  • E2E: 53/53 in Chromium
  • Both new guards proven red-then-green by removing a type forward and by adding a provider reference

ivanvyd added 7 commits July 30, 2026 16:23
The alert history's durability was asserted against the in-memory
provider only, which proves the logic and nothing about serialization --
and the log is the one entry a state provider holds that is not a
PulseCheckerState, under a key that is not a checker's name. Either could
have been an assumption a provider quietly relied on. Now round-tripped
through a real Redis and a real PostgreSQL, asserting the nullable enum
and the delivery flag survive, and that the log does not read back as a
checker.

MeterMetricsInsights had no tests at all. It reads instruments that are
internal to another assembly by meter name, so nothing about it is
checked by the compiler: rename an instrument and it keeps building and
silently counts nothing. These run real checks and assert the counters
moved, that the healthy share is null before anything has run rather than
a confident zero, and that disposal detaches the listener.
Scorecard raised four Pinned-Dependencies findings against the two sample
Dockerfiles: both took their .NET base images by tag. A tag is mutable,
so "sdk:10.0" is a different image next week and the build that passed
review is not the build that ships. Both are pinned by digest now, which
Dependabot updates the way it updates a package version.

The repository root had collected forty-four screenshots from unrelated
local work -- admin UIs, an email preview -- untracked but one `git add
-A` from being published. They are deleted, and root-level PNGs are
ignored so it cannot happen again. Anchored to the root rather than a
bare `*.png`, which would also have swallowed docs/assets and the
sample's favicon; both directions are asserted rather than assumed.
… guess

Both packages carried zero external dependencies, so splitting them out
bought a consumer nothing -- there was no driver to keep off anyone's
machine -- and cost them two more things to find and install. That is the
drift: the provider packages exist to keep Npgsql or Temporalio away from
people who do not use them, and these two were not providers.

The code now lives in Healthie.NET.DependencyInjection, under the
namespaces it always had, so no `using` changes. The old packages remain
published as assemblies of type forwards. That matters more than it
looks: moving a type between assemblies is a binary break the compiler
cannot see, because source keeps building and only the runtime finds out.
With the forwards, an application built against 4.0.0 keeps working
untouched -- which is what makes this a minor release rather than a
major.

Healthie.NET is new and is the package to install first. It carries no
code, only a dependency on the core package: the name people guess did
not exist, and the real entry point was called DependencyInjection, which
reads like plumbing rather than the way in. Deliberately not a bundle --
it must not drag a dashboard into a console app.

The tests assert against assembly-qualified names rather than the C#
types the compiler has already bound, because binding is exactly what
hides a missing forward.
… the suite

The README listed twenty packages as one flat table of equal weight,
which is what made this look like a twenty-package install when it has
always been a one-package install. Grouped now by what you are actually
deciding -- swap the storage, swap the scheduler, add a surface, add a
capability -- behind collapsed sections, with the single install and its
two-node dependency chain stated up front so nobody has to wonder whether
the meta-package is a bundle.

The metrics test double repeated a mistake this branch had already fixed
once: it derived from PulseChecker with a constructor the container
cannot satisfy, so assembly scanning failed twenty registration, MCP and
AI tests that have nothing to do with metrics. It went unnoticed because
the tests were run filtered rather than as a suite.

Its assertions were wrong in a second way. A MeterListener hears every
measurement in the process, so exact counters passed alone and failed
under the full suite. They are deltas with a floor now, and the healthy
share is asserted against a snapshot built directly, since the arithmetic
is what is under test rather than whatever else the suite was running.
CI could not find src/Healthie.Meta/Healthie.Meta.csproj because it was
never committed: the standard Visual Studio ignore block carries `*.meta`
for build artefacts, and that pattern matches a directory component too,
so the whole project folder was invisible. `git add -A` reported nothing,
the local build was happy because the files were on disk, and the first
sign of trouble was a restore failure on the runner.

Renamed to src/Healthie.NET.Package, after the package it produces, which
also reads better than "Meta". The ignore block is left alone: it is the
standard template and weakening it to rescue one folder name would trade
a naming collision for unignored build output.

The guard test follows the new path, and a locked-mode restore now
succeeds locally, which is the gate that actually failed.
… first

CI found this against a real PostgreSQL: two alerts recorded back to back
left one in the store. Record captured a snapshot and started a write
without waiting for the one before it, and nothing ordered them -- so the
older, shorter snapshot could land last and silently drop the newer
alert. It passed locally only because two writes to a fast disk happened
to finish in order.

The snapshot is now taken inside a write gate rather than at call time,
so whichever write goes last writes the newest state and the store
converges on what is actually held.

The regression test needed correcting twice before it was worth trusting.
It first polled until the store held two alerts and asserted -- which
passes the instant the fast write lands and looks away before the slow
one overwrites it, observing the very failure under test and calling it
success. It waits a fixed settle past both writes now. With the gate
removed it reports 2 expected, 1 actual.
@ivanvyd
ivanvyd merged commit 12ae5b8 into main Jul 30, 2026
7 of 8 checks passed
@ivanvyd
ivanvyd deleted the feature/core-packaging branch July 30, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant