diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs
new file mode 100644
index 0000000..f719516
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag;
+
+public class and_it_carries_no_prefix : Specification
+{
+ string? _result;
+
+ void Because() => _result = LatestVersion.NormalizeTag("2.3.6");
+
+ [Fact] void should_keep_the_version() => _result.ShouldEqual("2.3.6");
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs
new file mode 100644
index 0000000..7f031ea
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag;
+
+public class and_it_carries_the_release_prefix : Specification
+{
+ string? _result;
+
+ void Because() => _result = LatestVersion.NormalizeTag("v2.3.6");
+
+ [Fact] void should_drop_the_prefix() => _result.ShouldEqual("2.3.6");
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs
new file mode 100644
index 0000000..c77b725
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_normalizing_a_tag;
+
+public class and_it_is_empty : Specification
+{
+ string? _result;
+
+ void Because() => _result = LatestVersion.NormalizeTag(" ");
+
+ [Fact] void should_have_nothing_to_report() => _result.ShouldBeNull();
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs
new file mode 100644
index 0000000..165ac68
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source;
+
+public class and_installed_as_a_dotnet_tool : Specification
+{
+ LatestVersionSource _result;
+
+ void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.DotNetTool);
+
+ [Fact] void should_read_from_nuget() => _result.ShouldEqual(LatestVersionSource.NuGet);
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs
new file mode 100644
index 0000000..ce98d3f
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source;
+
+public class and_installed_as_a_linux_binary : Specification
+{
+ LatestVersionSource _result;
+
+ void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.ManualLinux);
+
+ [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease);
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs
new file mode 100644
index 0000000..0ce16b0
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source;
+
+public class and_installed_by_hand : Specification
+{
+ LatestVersionSource _result;
+
+ void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.Manual);
+
+ [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease);
+}
diff --git a/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs
new file mode 100644
index 0000000..019c46e
--- /dev/null
+++ b/Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_LatestVersion.when_resolving_the_source;
+
+///
+/// Homebrew upgrades from the tap, which the release workflow writes from a GitHub release - never NuGet.
+///
+public class and_installed_with_homebrew : Specification
+{
+ LatestVersionSource _result;
+
+ void Because() => _result = LatestVersion.SourceFor(CliUpdateStrategy.Homebrew);
+
+ [Fact] void should_read_from_the_github_release() => _result.ShouldEqual(LatestVersionSource.GitHubRelease);
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs
new file mode 100644
index 0000000..88317a6
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness;
+
+public class and_an_update_has_been_waiting_over_a_day : Specification
+{
+ static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc);
+
+ bool _result;
+
+ void Because() => _result = UpdateChecker.IsFresh("2.3.6", _now.AddHours(-25), "2.3.4", _now);
+
+ [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse();
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs
new file mode 100644
index 0000000..0e7b837
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness;
+
+///
+/// An update stays available until the user installs it, so this answer survives far longer.
+///
+public class and_an_update_is_waiting : Specification
+{
+ static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc);
+
+ bool _result;
+
+ void Because() => _result = UpdateChecker.IsFresh("2.3.6", _now.AddHours(-12), "2.3.4", _now);
+
+ [Fact] void should_still_be_trusted() => _result.ShouldBeTrue();
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs
new file mode 100644
index 0000000..d746eca
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness;
+
+///
+/// A check that lands in the window between a release being tagged and the package becoming visible records
+/// the previous version. Trusting that for a day means every release published after it goes unannounced for
+/// a day, which is exactly what happened between 2.3.4 and 2.3.6.
+///
+public class and_the_up_to_date_answer_has_aged : Specification
+{
+ static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc);
+
+ bool _result;
+
+ void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddHours(-2), "2.3.4", _now);
+
+ [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse();
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs
new file mode 100644
index 0000000..10ff0fc
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness;
+
+public class and_the_up_to_date_answer_is_recent : Specification
+{
+ static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc);
+
+ bool _result;
+
+ void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddMinutes(-30), "2.3.4", _now);
+
+ [Fact] void should_still_be_trusted() => _result.ShouldBeTrue();
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs
new file mode 100644
index 0000000..93c1d86
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_checking_freshness;
+
+///
+/// The cached answer is now behind what is installed, so it says nothing about what comes next.
+///
+public class and_the_user_has_updated_past_the_cached_answer : Specification
+{
+ static readonly DateTime _now = new(2026, 7, 30, 20, 0, 0, DateTimeKind.Utc);
+
+ bool _result;
+
+ void Because() => _result = UpdateChecker.IsFresh("2.3.4", _now.AddHours(-2), "2.3.6", _now);
+
+ [Fact] void should_no_longer_be_trusted() => _result.ShouldBeFalse();
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs
new file mode 100644
index 0000000..f563da0
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_keying_the_cache;
+
+public class and_the_source_is_nuget : Specification
+{
+ string _result = null!;
+
+ void Because() => _result = UpdateChecker.CacheKeyFor(LatestVersionSource.NuGet, UpdateChecker.CliPackageId);
+
+ [Fact] void should_key_by_the_package() => _result.ShouldEqual(UpdateChecker.CliPackageId);
+}
diff --git a/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs
new file mode 100644
index 0000000..b11e020
--- /dev/null
+++ b/Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_UpdateChecker.when_keying_the_cache;
+
+///
+/// Keyed apart from the package so an answer read from NuGet is never served to a native installation.
+///
+public class and_the_source_is_the_github_release : Specification
+{
+ string _result = null!;
+
+ void Because() => _result = UpdateChecker.CacheKeyFor(LatestVersionSource.GitHubRelease, UpdateChecker.CliPackageId);
+
+ [Fact] void should_not_collide_with_the_package_key() => _result.ShouldNotEqual(UpdateChecker.CliPackageId);
+}
diff --git a/Source/Cli/Commands/Version/SelfUpdateCommand.cs b/Source/Cli/Commands/Version/SelfUpdateCommand.cs
index 3088d41..52753b8 100644
--- a/Source/Cli/Commands/Version/SelfUpdateCommand.cs
+++ b/Source/Cli/Commands/Version/SelfUpdateCommand.cs
@@ -34,9 +34,11 @@ protected override async Task ExecuteAsync(CommandContext context, SelfUpda
using var updateCheckCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
+ // Asking for an update is the user saying the cached answer is not good enough, so this one
+ // always goes to the source.
expectedNewVersion = await RunWithStatus(
"Checking for updates...",
- () => UpdateChecker.CheckForUpdate(UpdateChecker.CliPackageId, currentVersion, updateCheckCts.Token));
+ () => UpdateChecker.CheckForCliUpdate(currentVersion, true, updateCheckCts.Token));
}
catch
{
diff --git a/Source/Cli/Commands/Version/VersionCommand.cs b/Source/Cli/Commands/Version/VersionCommand.cs
index 1243c15..3de2e2a 100644
--- a/Source/Cli/Commands/Version/VersionCommand.cs
+++ b/Source/Cli/Commands/Version/VersionCommand.cs
@@ -49,9 +49,10 @@ protected override async Task ExecuteAsync(CommandContext context, Chronicl
// Server unavailable, misconfigured, or doesn't support GetVersionInfo — all fine.
}
- // Check NuGet for newer versions — both fire in parallel and never block on failure.
+ // Check for newer versions — both fire in parallel and never block on failure. The CLI is checked
+ // against wherever this installation updates from, the server against its NuGet package.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
- var cliUpdateTask = UpdateChecker.CheckForUpdate(UpdateChecker.CliPackageId, cliVersion, cts.Token);
+ var cliUpdateTask = UpdateChecker.CheckForUpdate(cliVersion, cts.Token);
var serverUpdateTask = serverInfo is not null
? UpdateChecker.CheckForUpdate(UpdateChecker.ServerPackageId, serverInfo.Version, cts.Token)
: Task.FromResult(null);
diff --git a/Source/Cli/LatestVersion.cs b/Source/Cli/LatestVersion.cs
new file mode 100644
index 0000000..19cf1bb
--- /dev/null
+++ b/Source/Cli/LatestVersion.cs
@@ -0,0 +1,121 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli;
+
+///
+/// Reads the latest published version from the places a CLI installation can come from.
+///
+public static class LatestVersion
+{
+ ///
+ /// The GitHub repository the native downloads and the Homebrew formula are released from.
+ ///
+ public const string GitHubRepository = "Cratis/cli";
+
+ static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Resolves the place an installation updating through the given strategy should be compared against.
+ ///
+ /// The detected update strategy.
+ /// The to read from.
+ ///
+ /// Only the dotnet tool installs from NuGet. Every native installation - Homebrew, the Linux tarball, or a
+ /// binary put somewhere by hand - is built from a GitHub release, so that is the version those have to be
+ /// told about. The Homebrew formula is written by the same workflow after the release exists, which means
+ /// the release can never be behind the tap.
+ ///
+ public static LatestVersionSource SourceFor(CliUpdateStrategy strategy) =>
+ strategy == CliUpdateStrategy.DotNetTool
+ ? LatestVersionSource.NuGet
+ : LatestVersionSource.GitHubRelease;
+
+ ///
+ /// Reads the latest stable version of a package from NuGet.
+ ///
+ /// The NuGet package identifier.
+ /// A cancellation token.
+ /// The latest stable version, or null when it could not be read.
+ public static async Task FromNuGet(string packageId, CancellationToken cancellationToken = default)
+ {
+ using var http = new HttpClient { Timeout = _timeout };
+ var url = $"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json";
+ var response = await http.GetAsync(url, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var document = JsonDocument.Parse(json);
+
+ if (!document.RootElement.TryGetProperty("versions", out var versions) ||
+ versions.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+
+ // NuGet returns versions in ascending order; the last stable version is what we want.
+ string? latest = null;
+
+ foreach (var v in versions.EnumerateArray())
+ {
+ var versionString = v.GetString();
+ if (versionString?.Contains('-') == false)
+ {
+ latest = versionString;
+ }
+ }
+
+ return latest;
+ }
+
+ ///
+ /// Reads the version of the latest GitHub release.
+ ///
+ /// A cancellation token.
+ /// The latest released version, or null when it could not be read.
+ public static async Task FromGitHubRelease(CancellationToken cancellationToken = default)
+ {
+ using var http = new HttpClient { Timeout = _timeout };
+
+ // GitHub rejects requests without a user agent, and serves the release metadata under its own media type.
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("Cratis.Cli");
+ http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+
+ var url = $"https://api.github.com/repos/{GitHubRepository}/releases/latest";
+ var response = await http.GetAsync(url, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var document = JsonDocument.Parse(json);
+
+ return document.RootElement.TryGetProperty("tag_name", out var tag)
+ ? NormalizeTag(tag.GetString())
+ : null;
+ }
+
+ ///
+ /// Turns a release tag into a comparable version.
+ ///
+ /// The tag name, which the release workflow writes as v{version}.
+ /// The version without the tag prefix, or null when there was nothing to read.
+ internal static string? NormalizeTag(string? tagName)
+ {
+ if (string.IsNullOrWhiteSpace(tagName))
+ {
+ return null;
+ }
+
+ var trimmed = tagName.Trim();
+ return trimmed.StartsWith('v') || trimmed.StartsWith('V')
+ ? trimmed[1..]
+ : trimmed;
+ }
+}
diff --git a/Source/Cli/LatestVersionSource.cs b/Source/Cli/LatestVersionSource.cs
new file mode 100644
index 0000000..6818694
--- /dev/null
+++ b/Source/Cli/LatestVersionSource.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli;
+
+///
+/// Represents where the latest available version of the CLI should be read from.
+///
+///
+/// An installation has to be compared against the place it actually updates from. A tool installed with
+/// dotnet tool install updates from NuGet, while the native downloads - Homebrew included - update from
+/// the GitHub releases. Those are published by separate jobs and do not become visible at the same moment.
+///
+public enum LatestVersionSource
+{
+ ///
+ /// Read the latest version from the NuGet package feed.
+ ///
+ NuGet,
+
+ ///
+ /// Read the latest version from the GitHub releases.
+ ///
+ GitHubRelease
+}
diff --git a/Source/Cli/Program.cs b/Source/Cli/Program.cs
index 7a00808..294991c 100644
--- a/Source/Cli/Program.cs
+++ b/Source/Cli/Program.cs
@@ -4,9 +4,12 @@
using Cratis.Cli;
using Cratis.Cli.Commands.Version;
-using var updateCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var currentVersion = VersionCommand.GetCliVersion();
-var updateCheckTask = UpdateChecker.CheckForUpdate(currentVersion, updateCts.Token);
+
+// The request carries its own five second timeout. A deadline measured from here would instead be spent while
+// the command runs, so anything slower than that - the workbench, a run, generating a screenplay - would cancel
+// the check before it ever finished, leaving both the hint and the cached answer permanently out of reach.
+var updateCheckTask = UpdateChecker.CheckForUpdate(currentVersion);
if (args.Length == 0 && !Console.IsOutputRedirected && !GlobalSettings.IsAiAgentEnvironment())
{
diff --git a/Source/Cli/UpdateChecker.cs b/Source/Cli/UpdateChecker.cs
index 44a13e5..ec5b8ab 100644
--- a/Source/Cli/UpdateChecker.cs
+++ b/Source/Cli/UpdateChecker.cs
@@ -4,9 +4,13 @@
namespace Cratis.Cli;
///
-/// Checks NuGet for the latest available version of a package and caches the result.
-/// The check runs at most once per configured interval to avoid slowing down every command.
+/// Checks whether a newer version is available and caches the result, so that the check runs at most once per
+/// interval rather than on every command.
///
+///
+/// The CLI's own version is read from wherever the running installation updates from - NuGet for a dotnet tool,
+/// the GitHub releases for the native downloads - while other packages are read from NuGet.
+///
public static class UpdateChecker
{
///
@@ -25,7 +29,8 @@ public static class UpdateChecker
///
public const string DisableEnvVar = "CRATIS_NO_UPDATE_CHECK";
- static readonly TimeSpan _checkInterval = TimeSpan.FromHours(24);
+ static readonly TimeSpan _updateAvailableInterval = TimeSpan.FromHours(24);
+ static readonly TimeSpan _upToDateInterval = TimeSpan.FromHours(1);
static readonly JsonSerializerOptions _cacheJsonOptions = new() { WriteIndented = true };
///
@@ -43,13 +48,38 @@ public static string GetCachePath() =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cratis", "version-check.json");
///
- /// Checks whether a newer version of the CLI is available.
+ /// Checks whether a newer version of the CLI is available, from the place this installation updates from.
///
/// The current CLI version.
/// A cancellation token for timeout control.
/// The latest version string if newer, otherwise null.
public static Task CheckForUpdate(string currentVersion, CancellationToken cancellationToken = default)
- => CheckForUpdate(CliPackageId, currentVersion, cancellationToken);
+ => CheckForCliUpdate(currentVersion, false, cancellationToken);
+
+ ///
+ /// Checks whether a newer version of the CLI is available, from the place this installation updates from.
+ ///
+ /// The current CLI version.
+ /// Whether to ask the source directly rather than trusting the cached answer.
+ /// A cancellation token for timeout control.
+ /// The latest version string if newer, otherwise null.
+ ///
+ /// A dotnet tool is compared against NuGet and a native installation against the GitHub releases, because
+ /// those are published separately and comparing against the wrong one reports an update that the user's own
+ /// update command cannot yet install, or none when one is waiting.
+ ///
+ public static async Task CheckForCliUpdate(string currentVersion, bool bypassCache, CancellationToken cancellationToken = default)
+ {
+ var source = LatestVersion.SourceFor(CliUpdate.DetectStrategy());
+ return await Check(
+ CacheKeyFor(source, CliPackageId),
+ currentVersion,
+ bypassCache,
+ token => source == LatestVersionSource.NuGet
+ ? LatestVersion.FromNuGet(CliPackageId, token)
+ : LatestVersion.FromGitHubRelease(token),
+ cancellationToken);
+ }
///
/// Checks whether a newer version of the specified NuGet package is available.
@@ -61,39 +91,38 @@ public static string GetCachePath() =>
/// The current version.
/// A cancellation token for timeout control.
/// The latest version string if newer, otherwise null.
- public static async Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default)
- {
- if (IsDisabled())
- {
- return null;
- }
+ public static Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default) =>
+ Check(packageId, currentVersion, false, token => LatestVersion.FromNuGet(packageId, token), cancellationToken);
- var cache = ReadCache();
- if (cache?.Packages.TryGetValue(packageId, out var entry) == true &&
- DateTime.UtcNow - entry.CheckedAt < _checkInterval)
- {
- return IsNewer(entry.LatestVersion, currentVersion) ? entry.LatestVersion : null;
- }
-
- try
- {
- var latestVersion = await FetchLatestVersion(packageId, cancellationToken);
- if (latestVersion is null)
- {
- return null;
- }
-
- cache ??= new VersionCache();
- cache.Packages[packageId] = new PackageVersionEntry(latestVersion, DateTime.UtcNow);
- WriteCache(cache);
+ ///
+ /// Gets the cache key an answer read from the given source is stored under.
+ ///
+ /// The source the answer came from.
+ /// The package identifier, used when reading from NuGet.
+ /// The cache key.
+ ///
+ /// The sources are keyed apart so an answer read from one is never served for the other - the same
+ /// installation can change how it updates, and the two do not publish at the same moment.
+ ///
+ internal static string CacheKeyFor(LatestVersionSource source, string packageId) =>
+ source == LatestVersionSource.NuGet ? packageId : $"github:{LatestVersion.GitHubRepository}";
- return IsNewer(latestVersion, currentVersion) ? latestVersion : null;
- }
- catch
- {
- return null;
- }
- }
+ ///
+ /// Determines whether a cached answer can still be trusted.
+ ///
+ /// The latest version recorded when the check ran.
+ /// When the check ran.
+ /// The version currently installed.
+ /// The current time, in UTC.
+ /// True when the cached answer is still fresh enough to serve.
+ ///
+ /// The two answers do not go stale at the same rate. "An update is available" stays true until the user
+ /// updates, so it is held for a day. "You are up to date" stops being true the moment a release happens,
+ /// and a check landing in the window between the release and the package becoming visible records the
+ /// previous version - holding that for a day means the release goes unannounced for a day.
+ ///
+ internal static bool IsFresh(string cachedLatestVersion, DateTime checkedAt, string currentVersion, DateTime utcNow) =>
+ utcNow - checkedAt < (IsNewer(cachedLatestVersion, currentVersion) ? _updateAvailableInterval : _upToDateInterval);
///
/// Determines whether the latest version is newer than the current version.
@@ -111,39 +140,44 @@ internal static bool IsNewer(string latest, string current)
latestVer > currentVer;
}
- static async Task FetchLatestVersion(string packageId, CancellationToken cancellationToken)
+ static async Task Check(
+ string cacheKey,
+ string currentVersion,
+ bool bypassCache,
+ Func> fetch,
+ CancellationToken cancellationToken)
{
- using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
- var url = $"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json";
- var response = await http.GetAsync(url, cancellationToken);
-
- if (!response.IsSuccessStatusCode)
+ if (IsDisabled())
{
return null;
}
- var json = await response.Content.ReadAsStringAsync(cancellationToken);
- using var document = JsonDocument.Parse(json);
-
- if (!document.RootElement.TryGetProperty("versions", out var versions) ||
- versions.ValueKind != JsonValueKind.Array)
+ var cache = ReadCache();
+ if (!bypassCache &&
+ cache?.Packages.TryGetValue(cacheKey, out var entry) == true &&
+ IsFresh(entry.LatestVersion, entry.CheckedAt, currentVersion, DateTime.UtcNow))
{
- return null;
+ return IsNewer(entry.LatestVersion, currentVersion) ? entry.LatestVersion : null;
}
- // NuGet returns versions in ascending order; the last stable version is what we want.
- string? latest = null;
-
- foreach (var v in versions.EnumerateArray())
+ try
{
- var versionString = v.GetString();
- if (versionString?.Contains('-') == false)
+ var latestVersion = await fetch(cancellationToken);
+ if (latestVersion is null)
{
- latest = versionString;
+ return null;
}
- }
- return latest;
+ cache ??= new VersionCache();
+ cache.Packages[cacheKey] = new PackageVersionEntry(latestVersion, DateTime.UtcNow);
+ WriteCache(cache);
+
+ return IsNewer(latestVersion, currentVersion) ? latestVersion : null;
+ }
+ catch
+ {
+ return null;
+ }
}
static VersionCache? ReadCache()