Ofbirds.ReleaseNotifications 2.1.0

dotnet add package Ofbirds.ReleaseNotifications --version 2.1.0
                    
NuGet\Install-Package Ofbirds.ReleaseNotifications -Version 2.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ofbirds.ReleaseNotifications" Version="2.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ofbirds.ReleaseNotifications" Version="2.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Ofbirds.ReleaseNotifications" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ofbirds.ReleaseNotifications --version 2.1.0
                    
#r "nuget: Ofbirds.ReleaseNotifications, 2.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ofbirds.ReleaseNotifications@2.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ofbirds.ReleaseNotifications&version=2.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Ofbirds.ReleaseNotifications&version=2.1.0
                    
Install as a Cake Tool

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.11.2) announces.
  • Reads RELEASE_NOTES.md from 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 unversioned dev build.

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 IReleaseAudience KV store, under StateKeys.LastNotifiedVersion and StateKeys.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 a read:packages token 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.0 54 7/17/2026
2.0.0 142 7/5/2026
1.2.0 102 7/5/2026
1.1.0 101 7/5/2026
1.0.0 106 7/5/2026