Self-update for CLI tools built on Spectre.Console — check for new releases, download, verify, and atomically swap the install in place. Pluggable update sources (public GitHub Releases over HTTP, private repos via the gh CLI, generic HTTPS manifest, your own).
Stop hand-rolling the same "where's the latest release / what's my RID / where do I write the new EXE" code into every CLI you ship. Drop this package in, register a source, and my-cli update just works — with SHA-256 verification, an atomic file swap, a 24h read-through cache, and a magenta "new version available" banner that costs nothing on the warm path.
- Three built-in update sources, one extension point. Public GitHub Releases (HTTP), private GitHub Releases (via the
ghCLI), generic HTTPS JSON manifest, or implementIUpdateSourcefor anything else. - Asset resolution that just works. Format-agnostic resolver picks the right
.zip/.tar.gzfor the running RID with sensible fallbacks (Apple Silicon → Rosetta → universal). - SHA-256 verification by default. Reads from per-asset metadata when the source publishes it, or downloads a sibling
SHA256SUMS.txt. Stack additional verifiers (minisign, cosign, Authenticode) on top viaIPackageVerifier. - Atomic file swap, no restarter required. Stages under
.update/, swaps via.old/, cleans up.old/on the next launch — the running new binary is sufficient proof the swap succeeded. - Drop-in
updatecommand.CommandConfiguratorExtensions.AddUpdateCommand()for a single command,AddUpdateBranch()forupdate check/update apply. - Background check + post-run banner.
UpdateBanner.KickOffCheck()andRenderIfAvailable()wire up the pl-app-style UX in two lines. - Channels & pre-releases.
Channeloption flows through every source. - Configurable cache, timeouts, opt-out env var, dev-build skip predicate.
- Zero compiler warnings, full XML documentation on the public surface,
TreatWarningsAsErrors=true,AnalysisLevel=latest. SourceLink, deterministic builds, embedded symbols, snupkg symbol packages.
dotnet add package NextIteration.SpectreConsole.SelfUpdatevar services = new ServiceCollection();
services.AddSingleton<IAnsiConsole>(AnsiConsole.Console);
services.AddSelfUpdater(opts =>
{
opts.AppName = "myapp";
opts.UseGitHubReleases("acme/myapp");
});
using var sp = services.BuildServiceProvider();
sp.GetRequiredService<IUpdateInstaller>().CleanupOldInstall();
var checkTask = UpdateBanner.KickOffCheck(sp);
var app = new CommandApp(new YourTypeRegistrar(sp));
app.Configure(c =>
{
c.SetApplicationName("myapp");
c.AddUpdateCommand(); // adds `myapp update`
});
var exit = app.Run(args);
UpdateBanner.RenderIfAvailable(checkTask);
return exit;A complete demo (including a small ServiceProviderTypeRegistrar you can copy verbatim) lives in demo/.
| Source | Use it when | Configure |
|---|---|---|
HttpGitHubReleaseSource (default) |
Your repo is public, or you can supply a GITHUB_TOKEN. |
opts.UseGitHubReleases("owner/repo") |
GhCliReleaseSource |
Your repo is private and your users have gh auth set up. |
opts.UseGitHubReleases("owner/repo", GitHubTransport.GhCli) |
HttpManifestSource |
You publish releases to S3 / Azure Blob / a static site / your own backend. | opts.UseHttpManifest(new Uri("https://example.com/latest.json")) |
Custom IUpdateSource |
Anything else — internal artifact registry, signed CDN, etc. | opts.UseSource<MySource>() or opts.UseSource(sp => new MySource(...)) |
HttpManifestSource expects a JSON document with this shape — drop it on any HTTPS endpoint:
{
"tag": "v1.4.2",
"channel": "stable",
"publishedAt": "2026-04-30T12:00:00Z",
"releaseNotesUrl": "https://example.com/notes/v1.4.2",
"assets": [
{
"name": "myapp-v1.4.2-linux-x64.tar.gz",
"url": "https://example.com/dl/myapp-v1.4.2-linux-x64.tar.gz",
"sizeBytes": 12345678,
"contentType": "application/gzip",
"sha256": "abc123...64hex...def456"
}
]
}When sha256 is populated, the default verifier picks it up via ReleaseAsset.Metadata — no separate SHA256SUMS.txt needed.
public sealed class MyArtifactRegistrySource : IUpdateSource
{
public Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct) { /* ... */ }
public Task DownloadAssetAsync(ReleaseAsset asset, Stream destination,
IProgress<DownloadProgress>? progress, CancellationToken ct) { /* ... */ }
}
services.AddSelfUpdater(opts =>
{
opts.AppName = "myapp";
opts.UseSource<MyArtifactRegistrySource>();
});The contract is intentionally tiny: "what's the latest release?" and "stream me an asset". Everything else (asset resolution, verification, atomic swap, the update command) lives in the package.
The default resolver picks the archive whose filename matches the running RID. The expected naming convention is:
{app}-v{version}-{rid}.(zip|tar.gz|tgz)
Examples that all work without configuration: myapp-v1.4.2-linux-x64.tar.gz, myapp-1.4.2-osx-arm64.zip, myapp-osx-arm64.zip. Matching is case-insensitive, format-agnostic, and falls back through unversioned and rid-only patterns. osx-arm64 falls back to osx-x64 (Rosetta) and then bare osx (universal).
Override for releases with non-standard names:
opts.UseAssetResolver((release, rid) =>
release.Assets.FirstOrDefault(a => a.Name.Contains(rid) && a.Name.EndsWith(".zip")));Archive-only in v0.1. The atomic swap pipeline assumes a directory of files; single-file executables (myapp-linux-x64, no extension) are not currently supported. Ship a .zip or .tar.gz containing the executable and any sidecar files.
A SHA-256 verifier runs by default. It looks for a hash in two places, in order:
- The asset's
Metadata["sha256"]entry — populated automatically byHttpManifestSourcewhen the manifest publishes per-asset hashes. - A sibling
SHA256SUMS.txt(also acceptsSHA256SUMS,sha256sums.txt,sha256sums,checksums.txt) — downloaded via the registeredIUpdateSourceand parsed line-by-line.
If neither is present, the verifier throws — downloading without verification is unsafe. To opt out:
opts.UseDefaultSha256Verifier = false;To stack additional verifiers (signature checking, key pinning, your own policy), add them via AddVerifier:
opts.AddVerifier<MinisignVerifier>(); // type, resolved from DI
opts.AddVerifier(sp => new MyKeyPinningVerifier(sp.GetRequiredService<HttpClient>()));All verifiers are invoked in registration order; any one of them throwing aborts the install before extraction.
Set opts.Channel = "beta" and the source will look for tags containing -beta (the de-facto convention for SemVer prerelease tags). Set opts.IncludePrereleases = true to consider any prerelease — useful when you publish prereleases without a channel suffix.
For sources that don't have a channel concept (HttpManifestSource), host one manifest per channel — releases/stable/latest.json, releases/beta/latest.json — and configure the appropriate URL.
<install>/
├── myapp.exe # current install
├── settings.json
├── .update/ # transient; created during install
│ └── v1.4.2/ # staging dir for one specific tag
│ ├── myapp-v1.4.2-...-x64.zip
│ └── extracted/
│ └── myapp-v1.4.2-x64/
│ ├── myapp.exe # the new files
│ └── settings.json
├── .update.lock # transient; mutex during install
└── .old/ # transient; previous install
├── myapp.exe # moved here during the swap
└── settings.json # deleted on next startup
- Acquire lock.
<install>/.update.lockopened withFileShare.None+FileOptions.DeleteOnClose. Concurrent installs lose the race with a clear "another update is in progress" message. - Stage. Download the asset under
<install>/.update/<tag>/, run every registeredIPackageVerifier, extract. - Swap. Move every entry in
<install>/(except the maintenance dirs) into<install>/.old/. Copy the extracted files into place. Delete<install>/.update/. - Cleanup later. Next startup,
IUpdateInstaller.CleanupOldInstall()deletes<install>/.old/— the running new binary is proof the swap completed.
This avoids the "EXE is locked while running" problem on Windows without a separate restarter process.
By default the installer treats every entry in the install directory as
package-owned: anything that's not in the new release ends up in .old/
and gets cleaned up on next startup. That's wrong for files the user
placed there — appsettings.Development.json, a local SQLite database,
a data/ folder, scratch notes, plugins.
SelfUpdaterOptions.PreservePaths is a list of glob patterns identifying
top-level entries the installer should leave alone:
services.AddSelfUpdater(opts =>
{
opts.AppName = "myapp";
opts.UseGitHubReleases("acme/myapp");
opts.PreservePaths = new[]
{
"appsettings.Development.json", // exact filename
"appsettings.*.json", // glob over top-level files
"data/**", // whole top-level directory
"*.db", // sqlite or similar
};
});One-time gotcha when adopting
PreservePaths. This option was introduced in v0.1.3. The first time a user upgrades from a pre-0.1.3 build of your CLI to a 0.1.3+ build, the swap is performed by the old binary's update code — which doesn't know aboutPreservePathsand will move every file (including the ones you intend to preserve) into.old/, where they're wiped on the next launch. From the next update onwards (a 0.1.3+ binary upgrading to a newer 0.1.3+ binary), preservation works as documented. If your users already have important state in the install directory before this change ships, document a one-shot manual backup step or have your CLI bootstrap missing files on first launch after the upgrade.
Patterns match the top-level entry name (the part before the first
/ in the pattern). data/** and data/seed.json both preserve the
whole data/ directory; nested-only preservation isn't supported in
v0.1.x.
The PreservePaths property is a plain IReadOnlyList<string>, so
consumers can populate it from IConfiguration and merge with their
in-code defaults:
var fromConfig = configuration
.GetSection("SelfUpdate:Preserve")
.Get<string[]>() ?? Array.Empty<string>();
services.AddSelfUpdater(opts =>
{
opts.AppName = "myapp";
opts.UseGitHubReleases("acme/myapp");
opts.PreservePaths = new[]
{
"appsettings.Development.json",
"data/**",
}.Concat(fromConfig).ToArray();
});End users can then drop their own paths into appsettings.json (or
appsettings.Development.json next to the binary) without recompiling
the CLI:
{
"SelfUpdate": {
"Preserve": [ "my-custom-plugins/**", "notes.md" ]
}
}If a new release publishes an entry whose path matches one of the
preserve patterns, the installer asks the resolver passed to
InstallAsync what to do:
await selfUpdater.InstallAsync(
release,
progress: null,
onConflict: (conflict, ct) =>
{
// conflict.RelativePath, conflict.ExistingSizeBytes, conflict.NewSizeBytes
return Task.FromResult(UpdateConflictResolution.KeepExisting);
});Default (no resolver passed) is KeepExisting — the user's file wins.
The drop-in update command exposes a --strategy ask|keep|new flag:
with --yes it defaults to keep (so unattended runs never block on a
prompt); without --yes it defaults to ask and prompts the user
per file via Spectre's Confirm.
A common question when shipping a CLI: "users edit appsettings.json to
set their connection strings — how do I keep their edits without
losing the new options I add in a new release?"
The package's strong recommendation: don't try to merge. Use the
.NET layering convention instead, where appsettings.json is defaults
shipped by the package and appsettings.{Environment}.json (or
appsettings.Local.json, environment variables, Azure App
Configuration, etc.) is the user's overrides. IConfiguration
overlays them at runtime — the merge happens in memory, with no file
on disk to reconcile.
With that convention:
opts.PreservePaths = new[]
{
"appsettings.Development.json", // user-owned overrides — preserve
"appsettings.Local.json", // user-owned overrides — preserve
// appsettings.json is package-owned — let new releases update it
};A user who currently has connection strings in appsettings.json
should move them to appsettings.Local.json (or set
ASPNETCORE_ENVIRONMENT and use appsettings.{Environment}.json).
One-time migration; zero ongoing complexity. The base
appsettings.json keeps shipping new defaults and feature toggles
without ever clobbering user config.
If you can't move users to layered overrides — say a third-party tool
expects appsettings.json to be hand-edited — the onConflict
resolver is enough of an escape hatch to do a surgical merge. Keep
appsettings.json in PreservePaths, then in the resolver: read both
files, merge them however you like, write the result to the install
directory, and return KeepExisting so the installer leaves the
freshly-merged file alone:
opts.PreservePaths = new[] { "appsettings.json" };
await selfUpdater.InstallAsync(release, progress, onConflict: (conflict, ct) =>
{
if (conflict.RelativePath == "appsettings.json")
{
var existingPath = Path.Combine(installer.InstallDirectory, "appsettings.json");
var newPath = Path.Combine(stagingForThisRun, "appsettings.json");
// Bring your own merger — JsonNode walk, JObject.Merge, whatever
// matches your file's shape. Comments and formatting are
// typically lost on a JSON round-trip; consider that a reason
// to prefer layered overrides instead.
var merged = MergeJson(existingPath, newPath);
File.WriteAllText(existingPath, merged);
// Tell the installer "I've handled it — don't overwrite".
return Task.FromResult(UpdateConflictResolution.KeepExisting);
}
return Task.FromResult(UpdateConflictResolution.KeepExisting);
});A few caveats worth knowing:
- Without a base version, you can't tell user-modified from
unchanged. If the user has
LogLevel.Default = "Warning"and the new release ships"Information", was the user being deliberate or did they just inherit the previous default? A robust merger would need to persist the previous release'sappsettings.jsonsomewhere to use as a diff base. - JSON round-trips drop comments and reformat. Consumers who use inline comments in their config will notice.
- No built-in merger ships with this package. Config-file semantics are app-specific (deep-merge vs replace, array-append vs array-replace, …) and end up being a tar pit. The package stops at "preserve or overwrite per file"; merging policy belongs in the consumer.
| Property | Default | Description |
|---|---|---|
AppName |
(required) | Logical CLI name. Used for cache dir, env-var name, and asset resolution. |
Channel |
null |
Release-channel filter. null = source default. |
IncludePrereleases |
false |
When true, prerelease tags are eligible for "latest". |
CacheTtl |
24h |
How long the cached "latest tag" answer stays fresh. |
CheckTimeout |
3s |
Maximum time the background check spends talking to the source. |
DownloadTimeout |
5min |
Maximum time an asset download is allowed to take. |
CacheDirectory |
per-platform default | %APPDATA%/<app>/, ~/Library/Caches/<app>/, or $XDG_CACHE_HOME/<app>/. |
SkipCheckEnvironmentVariable |
<APP>_SKIP_UPDATE_CHECK |
Env var that suppresses the check when set to 1. |
SkipVersionPredicate |
v => v == "1.0.0" |
Returns true to suppress the check (for unstamped dev builds). |
GitHubToken |
null (then GITHUB_TOKEN/GH_TOKEN env) |
Optional bearer token for HttpGitHubReleaseSource. |
UseDefaultSha256Verifier |
true |
Whether to register the SHA-256 verifier automatically. |
AllowInsecureManifestSource |
false |
When true, HttpManifestSource accepts http:// URLs. Tests / internal mirrors only — plain HTTP defeats SHA-256 verification. |
PreservePaths |
[] |
Glob patterns identifying top-level entries the installer must leave alone (appsettings.Development.json, data/**, *.db, …). See Preserving user files. |
| Layer | Purpose | Default impl | Override |
|---|---|---|---|
IUpdateSource |
"What's the latest release? Stream me an asset." | one of the three built-ins, by configuration | opts.UseSource<T>() / UseSource(factory) |
IAssetResolver |
"Pick the right asset for the running RID." | DefaultAssetResolver |
opts.UseAssetResolver(...) |
IPackageVerifier |
"Confirm the bytes are what they claim to be." (multi-instance) | Sha256ChecksumVerifier |
opts.AddVerifier<T>(), set UseDefaultSha256Verifier=false to opt out |
IUpdateChecker |
"Is there a newer release?" with caching + opt-out | UpdateChecker |
replace via DI |
IUpdateInstaller |
Stage / verify / extract / swap / cleanup pipeline | UpdateInstaller |
replace via DI |
ISelfUpdater |
High-level façade for consumers | SelfUpdater |
replace via DI |
Every layer is pluggable. The contracts are intentionally small so a custom implementation only has to do the work that's actually different — the rest of the package keeps working.
MIT — © 2026 Stuart Meeks