Ofbirds.ReleaseNotifications
2.1.0
dotnet add package Ofbirds.ReleaseNotifications --version 2.1.0
NuGet\Install-Package Ofbirds.ReleaseNotifications -Version 2.1.0
<PackageReference Include="Ofbirds.ReleaseNotifications" Version="2.1.0" />
<PackageVersion Include="Ofbirds.ReleaseNotifications" Version="2.1.0" />
<PackageReference Include="Ofbirds.ReleaseNotifications" />
paket add Ofbirds.ReleaseNotifications --version 2.1.0
#r "nuget: Ofbirds.ReleaseNotifications, 2.1.0"
#:package Ofbirds.ReleaseNotifications@2.1.0
#addin nuget:?package=Ofbirds.ReleaseNotifications&version=2.1.0
#tool nuget:?package=Ofbirds.ReleaseNotifications&version=2.1.0
Ofbirds.ReleaseNotifications
A small, host-agnostic release-notification protocol, extracted from three sibling apps where it had been copy-pasted. On startup it emails every opted-in recipient that a new MAJOR.MINOR release line has shipped — once per release line, idempotently.
- Keys off the MAJOR.MINOR "release line" (
APP_VERSION=MAJOR.MINOR.<CI run>), so the auto-incrementing build segment doesn't re-email users on every redeploy. Only a hand bump of the MINOR (e.g.1.1→1.2) announces. - Reads
RELEASE_NOTES.mdfrom the app base directory on startup and renders it (bullets →<ul>, blank lines → paragraphs, everything HTML-encoded) into the email. - Best-effort per recipient: one bad address is logged and skipped, it never blocks the rest.
- Off unless
NOTIFY_ON_DEPLOY=true; never announces an unversioneddevbuild.
The library is a BackgroundService and knows nothing about your database. You supply the
recipients and a small persistent key/value store through IReleaseAudience. The library
uses that store for its own bookkeeping — which release line was last announced, and a fingerprint
of the notes last sent (see Not repeating stale notes).
The IReleaseAudience contract (you implement this)
The library never references any app's DbContext. Instead you implement IReleaseAudience
(typically over your own EF Core context) and register it in DI. The notifier resolves it
per scope from IServiceScopeFactory, exactly once per process start.
public interface IReleaseAudience
{
// Recipients ALREADY filtered to active + opted-in. Whoever this returns gets emailed.
Task<IReadOnlyList<ReleaseRecipient>> GetRecipientsAsync(CancellationToken ct);
// A tiny persistent key/value store the library uses for its own bookkeeping.
// Return null for an unset key. The store MUST survive restarts and redeploys.
Task<string?> GetStateAsync(string key, CancellationToken ct);
// Insert-or-update the value for `key`.
Task SetStateAsync(string key, string value, CancellationToken ct);
}
public sealed record ReleaseRecipient(string Email, string UnsubscribeToken);
The library owns the keys it uses — exposed as public constants on StateKeys so you (and your
tests) can reference the exact strings:
StateKeys.LastNotifiedVersion // "last_notified_version" — the last MAJOR.MINOR line announced
StateKeys.LastNotifiedNotesHash // "last_notified_notes_hash" — fingerprint of the notes last sent
Persist the values verbatim and don't interpret them; the library owns their meaning. The store must be durable across restarts and redeploys — that durability is what makes the stale-notes guard survive immutable-image deploys.
Example implementation over an app DbContext (one row per key in a settings table):
public sealed class DbReleaseAudience(AppDbContext db) : IReleaseAudience
{
public async Task<IReadOnlyList<ReleaseRecipient>> GetRecipientsAsync(CancellationToken ct) =>
await db.Users
.Where(u => u.IsActive && u.NotifyReleases)
.Select(u => new ReleaseRecipient(u.Email, u.UnsubscribeToken))
.ToListAsync(ct);
public async Task<string?> GetStateAsync(string key, CancellationToken ct) =>
(await db.SystemSettings.FindAsync([key], ct))?.Value;
public async Task SetStateAsync(string key, string value, CancellationToken ct)
{
var setting = await db.SystemSettings.FindAsync([key], ct);
if (setting is null)
db.SystemSettings.Add(new SystemSetting { Key = key, Value = value });
else
setting.Value = value;
await db.SaveChangesAsync(ct);
}
}
Wiring it up
AddReleaseNotifications registers the options, the SMTP IEmailSender (singleton), and the
ReleaseNotifier hosted service. It does not register IReleaseAudience or SmtpOptions —
those are yours to register, so the library stays free of any DbContext coupling.
using Ofbirds.ReleaseNotifications;
using Ofbirds.ReleaseNotifications.Email;
// 1. The library's own registrations. Set your product name here — it appears in the
// subject ("{AppName} — new version …") and body ("A new version of {AppName} …").
// Optionally brand the "Open {AppName}" button with your app's colors (see below).
builder.Services.AddReleaseNotifications(o =>
{
o.AppName = "Rosella Rhythm";
o.ButtonColor = "#E11D48"; // brand/primary color; defaults to violet "#7c3aed"
o.ButtonTextColor = "#ffffff"; // label color; defaults to white
});
// 2. YOUR audience implementation (scoped is typical — it wraps your DbContext).
builder.Services.AddScoped<IReleaseAudience, DbReleaseAudience>();
// 3. SmtpOptions, bound from your flat SMTP_* env keys (see below).
builder.Services.Configure<SmtpOptions>(o =>
{
o.Host = builder.Configuration["SMTP_HOST"] ?? "";
o.Port = int.TryParse(builder.Configuration["SMTP_PORT"], out var p) ? p : 465;
o.User = builder.Configuration["SMTP_USER"] ?? "";
o.Password = builder.Configuration["SMTP_PASSWORD"] ?? "";
o.From = builder.Configuration["SMTP_FROM"] ?? "";
o.AcceptAllCerts = string.Equals(builder.Configuration["SMTP_ACCEPT_ALL_CERTS"], "true",
StringComparison.OrdinalIgnoreCase);
});
ButtonColor / ButtonTextColor brand the "Open {AppName}" call-to-action. Both are optional and
default to violet-on-white (#7c3aed / #ffffff). The email always renders on a white card, so use
your app's light-theme button colors.
Environment / configuration
Read from IConfiguration by the notifier itself:
| Key | Meaning |
|---|---|
NOTIFY_ON_DEPLOY |
"true" to enable. Anything else → the notifier stays idle. |
APP_VERSION |
MAJOR.MINOR.<CI run>. dev or empty → nothing is sent. |
PUBLIC_BASE_URL |
Base for the "Open app" and …/api/unsubscribe?token=… links. |
SmtpOptions, bound by the host from the flat SMTP_* env keys (the library only consumes
IOptions<SmtpOptions>):
| Env key | SmtpOptions property |
Notes |
|---|---|---|
SMTP_HOST |
Host |
Empty host → IsConfigured is false. |
SMTP_PORT |
Port |
465 implicit TLS, 587 STARTTLS, else Auto |
SMTP_USER |
User |
|
SMTP_PASSWORD |
Password |
Keep in the env file, never in source. |
SMTP_FROM |
From |
|
SMTP_ACCEPT_ALL_CERTS |
AcceptAllCerts |
For ISP mail servers with non-chaining certs. |
Not repeating stale notes (persistent)
The operator is expected to hand-edit RELEASE_NOTES.md before each release. If they forget to
update it before bumping to a new MAJOR.MINOR line, the notifier would otherwise re-send the
previous release's notes verbatim. To prevent that, the notifier fingerprints the notes it
sends and, on the next new release line, checks whether the notes are unchanged — if so it sends a
generic "new version released!" body instead of the stale notes.
This is controlled by ReleaseNotificationOptions.GenericWhenNotesUnchanged, which defaults to
true:
builder.Services.AddReleaseNotifications(o =>
{
o.AppName = "Rosella Rhythm";
o.GenericWhenNotesUnchanged = true; // default; false = always send whatever the notes say
});
How it works — a fingerprint in the host's store, not a file.
- After reading
RELEASE_NOTES.md, the notifier computes a stable fingerprint of the notes: lowercase-hex SHA-256 of the UTF-8 bytes of the trimmed text. Empty/whitespace notes → the empty-string sentinel"". - On a confirmed success (at least one email sent, zero failures), it persists both the
MAJOR.MINOR release line and that fingerprint into your
IReleaseAudienceKV store, underStateKeys.LastNotifiedVersionandStateKeys.LastNotifiedNotesHash. - On the next run at a new release line, it re-reads the notes, recomputes the fingerprint, and compares it to the stored one. If they match (and the notes are non-empty), the operator forgot to update the notes → it sends the generic body. The subject still announces the new version; the fingerprint is still advanced so future comparisons are correct.
Why persistent (this is the v2.0.0 change). The fingerprint lives in your durable store, so the
guard survives process restarts and immutable-image redeploys. This replaces the old
ClearNotesAfterSend behavior (≤ v1.2.0), which rewrote the RELEASE_NOTES.md file in place after
sending — an approach that was ephemeral for the common case of an app that bakes
RELEASE_NOTES.md into an image rebuilt on every deploy (the next deploy restored the committed
notes, defeating the reset). No files are written any more.
Set GenericWhenNotesUnchanged = false to restore "always send whatever the notes currently say,"
even when they are identical to the last announcement.
Consuming the package
The package is published to nuget.org — the genuinely-public feed (anonymous restore, like
EntityFramework). Consuming apps need no auth, no nuget.config, no token — nuget.org is a
default source, so a plain reference just restores:
dotnet add package Ofbirds.ReleaseNotifications
This is deliberate: the consumers (e.g. the public Fuel/ThoseDays repos) build with zero
credentials. The source repo (OfBirds/dotnet-shared) is private, but the compiled package is
public — repo visibility and package visibility are independent.
The CI also mirror-pushes to the OfBirds GitHub Packages feed (
https://nuget.pkg.github.com/OfBirds/index.json), but that feed requires aread:packagestoken even for public packages, so it can't serve the public repos. Prefer nuget.org.
Build & test
The only .NET 10 SDK on the dev box lives outside PATH; use its full path:
/c/Users/Alexandr-Private-Acc/.dotnet10/dotnet.exe build Ofbirds.ReleaseNotifications.slnx
/c/Users/Alexandr-Private-Acc/.dotnet10/dotnet.exe test Ofbirds.ReleaseNotifications.slnx
License
MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MailKit (>= 4.17.0)
- Markdig (>= 1.3.2)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.9)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.