diff --git a/CHANGELOG.md b/CHANGELOG.md
index e15ef8f..96d750d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [0.1.4] — 2026-05-23
+
+### Added
+
+- **`update --prerelease` and `update check --prerelease`.** Opt into prerelease tags for a single command invocation without touching the DI-registered `SelfUpdaterOptions.IncludePrereleases` default. Useful for downstream apps testing RC builds. Help text: `Consider GitHub prereleases when looking for the latest version (off by default).`
+- **`bool? includePrereleasesOverride` parameter** on `IUpdateSource.GetLatestAsync`, `IUpdateChecker.CheckAsync`, and `ISelfUpdater.GetLatestReleaseAsync`, added as default interface methods that delegate to the existing overload. External `IUpdateSource` implementers continue to compile unchanged; they only need to override the new overload if they want to honour `--prerelease`. `null` defers to the source's captured default; `true`/`false` force inclusion or exclusion.
+
+### Changed
+
+- The update-check cache now keys on `(channel, includePrereleases)` so a `--prerelease` answer doesn't pollute the next default `update check`, and vice versa. Cache files written by v0.1.3 are read as non-prerelease (matches their actual provenance — prereleases were always opt-in at DI registration). No migration needed; the new field is nullable.
+
+---
+
## [0.1.3] — 2026-05-03
### Added
@@ -72,6 +85,7 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the
- Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`.
- SourceLink, deterministic builds, published symbol packages.
+[0.1.4]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.4
[0.1.3]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.3
[0.1.2]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.2
[0.1.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.1
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs
index cdba4ef..a929351 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCheckCommand.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using System.Globalization;
using Spectre.Console;
@@ -16,8 +17,22 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Commands
/// - 2 — a newer release is available.
///
///
- public sealed class UpdateCheckCommand : AsyncCommand
+ public sealed class UpdateCheckCommand : AsyncCommand
{
+ /// Settings for update check.
+ public sealed class Settings : CommandSettings
+ {
+ ///
+ /// Opt into prerelease tags for this invocation only. Equivalent
+ /// to flipping SelfUpdaterOptions.IncludePrereleases to
+ /// true for the lifetime of this command without mutating
+ /// shared options.
+ ///
+ [CommandOption("--prerelease")]
+ [Description("Consider GitHub prereleases when looking for the latest version (off by default).")]
+ public bool Prerelease { get; init; }
+ }
+
private readonly IUpdateChecker _checker;
private readonly IAnsiConsole _console;
@@ -32,15 +47,18 @@ public UpdateCheckCommand(IUpdateChecker checker, IAnsiConsole console)
}
///
- protected override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
+ ArgumentNullException.ThrowIfNull(settings);
+
var current = _checker.GetCurrentVersion() ?? "dev";
_console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Current version: [bold]{current}[/]");
+ bool? prereleaseOverride = settings.Prerelease ? true : null;
UpdateInfo? info;
try
{
- info = await _checker.CheckAsync(cancellationToken).ConfigureAwait(false);
+ info = await _checker.CheckAsync(prereleaseOverride, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
index ebafe23..bdea55c 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs
@@ -42,6 +42,16 @@ public sealed class Settings : CommandSettings
[CommandOption("--strategy")]
[Description("Conflict strategy when a release ships a preserved path: ask | keep | new.")]
public string? Strategy { get; init; }
+
+ ///
+ /// Opt into prerelease tags for this invocation only. Equivalent
+ /// to flipping SelfUpdaterOptions.IncludePrereleases to
+ /// true for the lifetime of this command without mutating
+ /// shared options.
+ ///
+ [CommandOption("--prerelease")]
+ [Description("Consider GitHub prereleases when looking for the latest version (off by default).")]
+ public bool Prerelease { get; init; }
}
private enum ConflictStrategy { Ask, Keep, New }
@@ -80,10 +90,11 @@ protected override async Task ExecuteAsync(CommandContext context, Settings
// Fetch the release once and use it for both display and install,
// so the user confirms exactly the release that gets installed
// (no TOCTOU window between "what's latest?" and "install latest").
+ bool? prereleaseOverride = settings.Prerelease ? true : null;
RemoteRelease? release;
try
{
- release = await _selfUpdater.GetLatestReleaseAsync(cancellationToken).ConfigureAwait(false);
+ release = await _selfUpdater.GetLatestReleaseAsync(prereleaseOverride, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
index 5140bb3..6d8eaac 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
@@ -10,7 +10,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate
public interface ISelfUpdater
{
///
- /// Non-blocking probe — see .
+ /// Non-blocking probe — see .
///
Task CheckAsync(CancellationToken ct = default);
@@ -26,6 +26,20 @@ public interface ISelfUpdater
///
Task GetLatestReleaseAsync(CancellationToken ct = default);
+ ///
+ /// Per-invocation variant of
+ /// that lets the
+ /// caller override
+ /// for one call (used by the update --prerelease CLI flag).
+ /// defers to the configured option;
+ /// / force inclusion or
+ /// exclusion. The default-interface implementation drops the override
+ /// and delegates to the base overload so existing custom updaters
+ /// continue to compile.
+ ///
+ Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
+ GetLatestReleaseAsync(ct);
+
///
/// Install the supplied release: download, run the verifier
/// pipeline, extract the archive, and swap the new files into the
@@ -54,8 +68,8 @@ Task InstallAsync(
/// when no release is available or
/// when any pipeline stage fails. Has a TOCTOU window: the
/// release returned by the source here may differ from the one a
- /// prior reported. For interactive UIs,
- /// prefer the +
+ /// prior reported. For interactive UIs,
+ /// prefer the +
///
/// pair so the user confirms exactly the release that gets
/// installed.
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs
index 32b6acb..68c2c47 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs
@@ -18,6 +18,20 @@ public interface IUpdateChecker
///
Task CheckAsync(CancellationToken ct = default);
+ ///
+ /// Per-invocation variant of
+ /// that lets the caller override
+ /// for one call
+ /// (used by the update check --prerelease CLI flag).
+ /// defers to the configured option;
+ /// / force inclusion or
+ /// exclusion. The default-interface implementation drops the override
+ /// and delegates to the base overload so existing custom checkers
+ /// continue to compile.
+ ///
+ Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
+ CheckAsync(ct);
+
///
/// The running CLI's version, read from
///
@@ -27,7 +41,7 @@ public interface IUpdateChecker
/// is
/// not consulted here — predicate-skipped versions are still
/// reported so commands can display them; the predicate suppresses
- /// the check inside , not the
+ /// the check inside , not the
/// displayed version.
///
string? GetCurrentVersion();
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs
index c7187c3..3b5d5bf 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs
@@ -28,6 +28,27 @@ public interface IUpdateSource
/// Cancellation token honoured for both DNS and stream reads.
Task GetLatestAsync(string? channel, CancellationToken ct);
+ ///
+ /// Per-invocation variant of
+ /// that lets the caller override
+ /// without
+ /// mutating shared options. Used by the update --prerelease
+ /// and update check --prerelease CLI flags.
+ ///
+ /// Channel filter — see the base overload.
+ ///
+ /// defers to the source's captured
+ /// ;
+ /// forces prerelease inclusion for this call;
+ /// forces exclusion. The default interface implementation drops the
+ /// override and delegates to the base overload so existing third-party
+ /// sources continue to compile — implementers wanting to honour the
+ /// CLI flag should override this method explicitly.
+ ///
+ /// Cancellation token.
+ Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) =>
+ GetLatestAsync(channel, ct);
+
///
/// Stream a single release asset to .
/// Should write nothing on failure and propagate the underlying
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
index 60967fa..3ce9b83 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj
@@ -11,7 +11,7 @@
NextIteration.SpectreConsole.SelfUpdate
- 0.1.3
+ 0.1.4
Stuart Meeks
Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command.
true
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
index f4aa2c3..cfcb59c 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs
@@ -34,7 +34,10 @@ public SelfUpdater(
_checker.CheckAsync(ct);
public Task GetLatestReleaseAsync(CancellationToken ct = default) =>
- _source.GetLatestAsync(_options.Channel, ct);
+ GetLatestReleaseAsync(includePrereleasesOverride: null, ct);
+
+ public Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
+ _source.GetLatestAsync(_options.Channel, includePrereleasesOverride, ct);
public Task InstallAsync(
RemoteRelease release,
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs
index b0deaaf..209e7d4 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs
@@ -51,5 +51,6 @@ internal sealed record UpdateCacheEntry(
[property: JsonPropertyName("checkedAt")] DateTimeOffset CheckedAt,
[property: JsonPropertyName("latestTag")] string LatestTag,
[property: JsonPropertyName("releaseUrl")] string? ReleaseUrl,
- [property: JsonPropertyName("channel")] string? Channel);
+ [property: JsonPropertyName("channel")] string? Channel,
+ [property: JsonPropertyName("includePrereleases")] bool? IncludePrereleases = null);
}
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs
index 77bcde4..ae04d08 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs
@@ -45,7 +45,10 @@ internal UpdateChecker(
_utcNow = utcNow;
}
- public async Task CheckAsync(CancellationToken ct = default)
+ public Task CheckAsync(CancellationToken ct = default) =>
+ CheckAsync(includePrereleasesOverride: null, ct);
+
+ public async Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default)
{
if (IsOptOutSet()) return null;
@@ -53,9 +56,10 @@ internal UpdateChecker(
if (current is null) return null;
if (IsVersionSkipped(current)) return null;
+ var effectivePrerelease = includePrereleasesOverride ?? _options.IncludePrereleases;
var cachePath = ResolveCacheFilePath();
var cached = UpdateCacheFile.TryRead(cachePath);
- if (IsCacheFresh(cached))
+ if (IsCacheFresh(cached, effectivePrerelease))
{
return Compare(current, cached!.LatestTag, ParseUri(cached.ReleaseUrl));
}
@@ -65,14 +69,15 @@ internal UpdateChecker(
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_options.CheckTimeout);
- var release = await _source.GetLatestAsync(_options.Channel, linked.Token).ConfigureAwait(false);
+ var release = await _source.GetLatestAsync(_options.Channel, includePrereleasesOverride, linked.Token).ConfigureAwait(false);
if (release is null) return null;
UpdateCacheFile.TryWrite(cachePath, new UpdateCacheEntry(
CheckedAt: _utcNow(),
LatestTag: release.Tag,
ReleaseUrl: release.ReleaseNotesUrl?.ToString(),
- Channel: _options.Channel));
+ Channel: _options.Channel,
+ IncludePrereleases: effectivePrerelease));
return Compare(current, release.Tag, release.ReleaseNotesUrl);
}
@@ -122,10 +127,17 @@ internal string ResolveCacheFilePath()
return Path.Combine(dir, ".update-check.json");
}
- internal bool IsCacheFresh(UpdateCacheEntry? entry)
+ internal bool IsCacheFresh(UpdateCacheEntry? entry) =>
+ IsCacheFresh(entry, _options.IncludePrereleases);
+
+ internal bool IsCacheFresh(UpdateCacheEntry? entry, bool effectivePrerelease)
{
if (entry is null) return false;
if (!string.Equals(entry.Channel, _options.Channel, StringComparison.Ordinal)) return false;
+ // Pre-0.1.4 cache entries have no IncludePrereleases field — they
+ // were written before the override existed, so they always
+ // reflect a non-prerelease answer.
+ if ((entry.IncludePrereleases ?? false) != effectivePrerelease) return false;
return (_utcNow() - entry.CheckedAt) < _options.CacheTtl;
}
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
index d451adf..423a3cf 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs
@@ -19,7 +19,7 @@ public sealed class SelfUpdaterOptions
///
/// Optional release channel filter, e.g. "stable",
- /// "beta". Forwarded to ;
+ /// "beta". Forwarded to ;
/// the source decides what the value means.
/// uses the source's default channel.
///
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs
index b39b3f7..500f768 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs
@@ -27,7 +27,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Sources
/// --pattern <name> --output <tempfile> followed by streaming
/// the temp file into the caller's destination. The tag is recovered
/// from (key "tag") which
- /// the source populates during . Arguments
+ /// the source populates during . Arguments
/// are passed via
/// so values from a remote source never need to be quoted or escaped.
///
@@ -74,11 +74,16 @@ internal GhCliReleaseSource(
}
///
- public async Task GetLatestAsync(string? channel, CancellationToken ct)
+ public Task GetLatestAsync(string? channel, CancellationToken ct) =>
+ GetLatestAsync(channel, includePrereleasesOverride: null, ct);
+
+ ///
+ public async Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct)
{
+ var includePrereleases = includePrereleasesOverride ?? _includePrereleases;
try
{
- if (channel is null && !_includePrereleases)
+ if (channel is null && !includePrereleases)
{
var stdout = await _runner(
new[]
@@ -106,7 +111,7 @@ internal GhCliReleaseSource(
var releases = JsonSerializer.Deserialize(listJson, JsonOpts) ?? Array.Empty();
var match = releases
.Where(r => !r.IsDraft)
- .Where(r => _includePrereleases || !r.IsPrerelease)
+ .Where(r => includePrereleases || !r.IsPrerelease)
.Where(r => channel is null
|| (r.TagName ?? string.Empty).Contains($"-{channel}", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.PublishedAt)
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs
index a20f8d3..2d81fc1 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs
@@ -83,14 +83,19 @@ internal HttpGitHubReleaseSource(
}
///
- public async Task GetLatestAsync(string? channel, CancellationToken ct)
+ public Task GetLatestAsync(string? channel, CancellationToken ct) =>
+ GetLatestAsync(channel, includePrereleasesOverride: null, ct);
+
+ ///
+ public async Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct)
{
+ var includePrereleases = includePrereleasesOverride ?? _includePrereleases;
try
{
using var http = _httpClientFactory.CreateClient();
ConfigureRequestHeaders(http);
- if (channel is null && !_includePrereleases)
+ if (channel is null && !includePrereleases)
{
var dto = await GetJsonAsync(http, $"repos/{_repository}/releases/latest", ct).ConfigureAwait(false);
return dto is null ? null : Convert(dto, channel);
@@ -101,7 +106,7 @@ internal HttpGitHubReleaseSource(
var match = releases
.Where(r => !r.Draft)
- .Where(r => _includePrereleases || !r.Prerelease)
+ .Where(r => includePrereleases || !r.Prerelease)
.Where(r => channel is null
|| (r.TagName ?? string.Empty).Contains($"-{channel}", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.PublishedAt)
diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs
index dcbdfad..5a19a8e 100644
--- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs
+++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs
@@ -95,6 +95,15 @@ public HttpManifestSource(IHttpClientFactory httpClientFactory, Uri manifestUrl,
private static bool IsHttps(Uri uri) =>
string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
+ ///
+ ///
+ /// The single-release manifest does not expose a prerelease flag, so the
+ /// includePrereleasesOverride overload simply delegates here — the
+ /// manifest's own tag is always returned regardless of override.
+ ///
+ public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) =>
+ GetLatestAsync(channel, ct);
+
///
public async Task GetLatestAsync(string? channel, CancellationToken ct)
{
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs
index 677fac2..72793d4 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs
@@ -13,10 +13,42 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Tests.Commands
{
public sealed class UpdateCheckCommandTests
{
+ private delegate Task Runner(params string[] args);
+
+ [Fact]
+ public async Task Execute_with_prerelease_flag_passes_true_override_to_checker()
+ {
+ var (run, _, checker) = BuildHarness(c =>
+ {
+ c.CurrentVersion = "1.0.0";
+ c.CheckImpl = _ => Task.FromResult(
+ new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null));
+ });
+
+ await run("--prerelease");
+
+ Assert.True(checker.LastIncludePrereleasesOverride);
+ }
+
+ [Fact]
+ public async Task Execute_without_prerelease_flag_passes_null_override_to_checker()
+ {
+ var (run, _, checker) = BuildHarness(c =>
+ {
+ c.CurrentVersion = "1.0.0";
+ c.CheckImpl = _ => Task.FromResult(
+ new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null));
+ });
+
+ await run();
+
+ Assert.Null(checker.LastIncludePrereleasesOverride);
+ }
+
[Fact]
public async Task Execute_when_up_to_date_returns_zero()
{
- var (run, console) = BuildHarness(checker =>
+ var (run, console, _) = BuildHarness(checker =>
{
checker.CurrentVersion = "1.4.2";
checker.CheckImpl = _ => Task.FromResult(
@@ -32,7 +64,7 @@ public async Task Execute_when_up_to_date_returns_zero()
[Fact]
public async Task Execute_when_update_available_returns_two()
{
- var (run, console) = BuildHarness(checker =>
+ var (run, console, _) = BuildHarness(checker =>
{
checker.CurrentVersion = "1.0.0";
checker.CheckImpl = _ => Task.FromResult(
@@ -51,7 +83,7 @@ public async Task Execute_when_update_available_returns_two()
[Fact]
public async Task Execute_when_check_returns_null_returns_one()
{
- var (run, console) = BuildHarness(checker =>
+ var (run, console, _) = BuildHarness(checker =>
checker.CheckImpl = _ => Task.FromResult(null));
var exit = await run();
@@ -63,7 +95,7 @@ public async Task Execute_when_check_returns_null_returns_one()
[Fact]
public async Task Execute_when_check_throws_returns_one()
{
- var (run, console) = BuildHarness(checker =>
+ var (run, console, _) = BuildHarness(checker =>
checker.CheckImpl = _ => throw new InvalidOperationException("offline"));
var exit = await run();
@@ -76,7 +108,7 @@ public async Task Execute_when_check_throws_returns_one()
[Fact]
public async Task Execute_when_no_release_url_omits_release_notes_line()
{
- var (run, console) = BuildHarness(checker =>
+ var (run, console, _) = BuildHarness(checker =>
{
checker.CurrentVersion = "1.0.0";
checker.CheckImpl = _ => Task.FromResult(
@@ -91,7 +123,7 @@ public async Task Execute_when_no_release_url_omits_release_notes_line()
// ---------- helpers ----------
- private static (Func> Run, TestConsole Console) BuildHarness(Action configChecker)
+ private static (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action configChecker)
{
var checker = new StubUpdateChecker();
configChecker(checker);
@@ -107,8 +139,8 @@ private static (Func> Run, TestConsole Console) BuildHarness(Action(registrar);
app.Configure(c => c.PropagateExceptions());
- Task Run() => app.RunAsync(Array.Empty());
- return (Run, console);
+ Runner run = args => app.RunAsync(args);
+ return (run, console, checker);
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
index db86a45..c256761 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs
@@ -196,6 +196,39 @@ public async Task Execute_when_force_and_up_to_date_proceeds_to_install()
Assert.Equal(1, installCalls);
}
+ [Fact]
+ public async Task Execute_with_prerelease_flag_passes_true_override_to_updater()
+ {
+ var (run, _, updater) = BuildHarness(
+ configChecker: c => c.CurrentVersion = "1.0.0",
+ configUpdater: u =>
+ {
+ u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
+ u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask;
+ });
+
+ var exit = await run("--yes", "--prerelease");
+
+ Assert.Equal(0, exit);
+ Assert.True(updater.LastIncludePrereleasesOverride);
+ }
+
+ [Fact]
+ public async Task Execute_without_prerelease_flag_passes_null_override_to_updater()
+ {
+ var (run, _, updater) = BuildHarness(
+ configChecker: c => c.CurrentVersion = "1.0.0",
+ configUpdater: u =>
+ {
+ u.GetLatestImpl = _ => Task.FromResult(ReleaseV142);
+ u.InstallReleaseImpl = (_, _, _, _) => Task.CompletedTask;
+ });
+
+ await run("--yes");
+
+ Assert.Null(updater.LastIncludePrereleasesOverride);
+ }
+
[Fact]
public async Task Execute_prints_current_and_latest_versions()
{
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs
index 2f90c27..7276760 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeUpdateSource.cs
@@ -11,11 +11,16 @@ internal sealed class FakeUpdateSource : IUpdateSource
public Func? AssetBytes { get; set; }
public int GetLatestCallCount { get; private set; }
public string? LastChannelRequested { get; private set; }
+ public bool? LastIncludePrereleasesOverride { get; private set; }
- public Task GetLatestAsync(string? channel, CancellationToken ct)
+ public Task GetLatestAsync(string? channel, CancellationToken ct) =>
+ GetLatestAsync(channel, includePrereleasesOverride: null, ct);
+
+ public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct)
{
GetLatestCallCount++;
LastChannelRequested = channel;
+ LastIncludePrereleasesOverride = includePrereleasesOverride;
var result = LatestSelector?.Invoke(channel) ?? LatestForChannel;
return Task.FromResult(result);
}
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
index dbbe6db..ce13dd1 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs
@@ -7,12 +7,19 @@ internal sealed class StubSelfUpdater : ISelfUpdater
public Func>? GetLatestImpl { get; set; }
public Func?, CancellationToken, Task>? InstallImpl { get; set; }
public Func?, Func>?, CancellationToken, Task>? InstallReleaseImpl { get; set; }
+ public bool? LastIncludePrereleasesOverride { get; private set; }
public Task CheckAsync(CancellationToken ct = default) =>
CheckImpl?.Invoke(ct) ?? Task.FromResult(null);
public Task GetLatestReleaseAsync(CancellationToken ct = default) =>
- GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null);
+ GetLatestReleaseAsync(includePrereleasesOverride: null, ct);
+
+ public Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default)
+ {
+ LastIncludePrereleasesOverride = includePrereleasesOverride;
+ return GetLatestImpl?.Invoke(ct) ?? Task.FromResult(null);
+ }
public Task InstallAsync(
RemoteRelease release,
@@ -30,9 +37,16 @@ internal sealed class StubUpdateChecker : IUpdateChecker
{
public string? CurrentVersion { get; set; } = "1.0.0";
public Func>? CheckImpl { get; set; }
+ public bool? LastIncludePrereleasesOverride { get; private set; }
public Task CheckAsync(CancellationToken ct = default) =>
- CheckImpl?.Invoke(ct) ?? Task.FromResult(null);
+ CheckAsync(includePrereleasesOverride: null, ct);
+
+ public Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default)
+ {
+ LastIncludePrereleasesOverride = includePrereleasesOverride;
+ return CheckImpl?.Invoke(ct) ?? Task.FromResult(null);
+ }
public string? GetCurrentVersion() => CurrentVersion;
}
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs
index bb7db7c..3113f4d 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateCheckerTests.cs
@@ -116,6 +116,27 @@ public async Task CheckAsync_returns_update_available_when_remote_is_newer()
Assert.Equal("v1.0.5", info.LatestTag);
}
+ [Fact]
+ public async Task CheckAsync_override_true_does_not_reuse_default_cache_entry()
+ {
+ using var dir = new TempDir();
+ var opts = new SelfUpdaterOptions { AppName = "myapp", CacheDirectory = dir.Path, SkipVersionPredicate = null };
+ var source = new FakeUpdateSource
+ {
+ LatestSelector = _ => TestRelease("v1.5.0"),
+ };
+ var checker = NewChecker(opts, source, "1.0.0");
+
+ // First call without override populates the default-prerelease cache.
+ await checker.CheckAsync();
+ Assert.Equal(1, source.GetLatestCallCount);
+
+ // Second call with override=true must not reuse the default cache.
+ await checker.CheckAsync(includePrereleasesOverride: true);
+ Assert.Equal(2, source.GetLatestCallCount);
+ Assert.True(source.LastIncludePrereleasesOverride);
+ }
+
[Fact]
public async Task CheckAsync_returns_no_update_when_remote_is_same()
{
diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs
index 0adf361..0db2f35 100644
--- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs
+++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Sources/HttpGitHubReleaseSourceTests.cs
@@ -90,6 +90,31 @@ public async Task GetLatestAsync_with_includePrereleases_filters_drafts_only()
Assert.Equal("v2.0.0-rc.1", release?.Tag);
}
+ [Fact]
+ public async Task GetLatestAsync_with_override_true_hits_list_endpoint_when_constructor_says_false()
+ {
+ // Constructor was passed includePrereleases:false, but the
+ // per-invocation override should take over and force the source
+ // onto the listing endpoint that returns prereleases.
+ const string json = """
+ [
+ { "tag_name": "v2.0.0-rc.1", "draft": false, "prerelease": true, "published_at": "2026-05-02T12:00:00Z", "assets": [] },
+ { "tag_name": "v1.4.2", "draft": false, "prerelease": false, "published_at": "2026-04-30T12:00:00Z", "assets": [] }
+ ]
+ """;
+ HttpRequestMessage? captured = null;
+ var handler = new FakeHttpHandler
+ {
+ Responder = req => { captured = req; return FakeHttpHandler.Json(json); },
+ };
+ var source = NewSource(handler, includePrereleases: false);
+
+ var release = await source.GetLatestAsync(channel: null, includePrereleasesOverride: true, CancellationToken.None);
+
+ Assert.Equal("v2.0.0-rc.1", release?.Tag);
+ Assert.DoesNotContain("/latest", captured?.RequestUri?.AbsolutePath ?? string.Empty, StringComparison.Ordinal);
+ }
+
[Fact]
public async Task GetLatestAsync_when_request_fails_returns_null()
{