From ff5298ad577f8ae556201de43fbf6d40e38cdc27 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 30 Jul 2026 22:02:43 +0200 Subject: [PATCH 1/3] Check for updates against the place the installation updates from The update check always asked NuGet, but only a dotnet tool installs from there. The native downloads - Homebrew included - are built from a GitHub release, and the two are published by separate jobs that do not become visible at the same moment. A Homebrew user was therefore told about a package they never install, and gets no hint at all if the NuGet push ever fails while the native publish succeeds. Read the CLI's own version from wherever the running installation updates from, and key the cached answers apart so one source is never served for the other. Other packages, such as the server version, still come from NuGet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UNSXxJz2S2SxP3piPPVK4K --- .../and_it_carries_no_prefix.cs | 13 ++ .../and_it_carries_the_release_prefix.cs | 13 ++ .../when_normalizing_a_tag/and_it_is_empty.cs | 13 ++ .../and_installed_as_a_dotnet_tool.cs | 13 ++ .../and_installed_as_a_linux_binary.cs | 13 ++ .../and_installed_by_hand.cs | 13 ++ .../and_installed_with_homebrew.cs | 16 +++ .../and_the_source_is_nuget.cs | 13 ++ .../and_the_source_is_the_github_release.cs | 16 +++ .../Cli/Commands/Version/SelfUpdateCommand.cs | 2 +- Source/Cli/Commands/Version/VersionCommand.cs | 5 +- Source/Cli/LatestVersion.cs | 121 ++++++++++++++++++ Source/Cli/LatestVersionSource.cs | 25 ++++ Source/Cli/UpdateChecker.cs | 117 ++++++++--------- 14 files changed, 333 insertions(+), 60 deletions(-) create mode 100644 Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_no_prefix.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_carries_the_release_prefix.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_normalizing_a_tag/and_it_is_empty.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_dotnet_tool.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_as_a_linux_binary.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_by_hand.cs create mode 100644 Source/Cli.Specs/for_LatestVersion/when_resolving_the_source/and_installed_with_homebrew.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_nuget.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_keying_the_cache/and_the_source_is_the_github_release.cs create mode 100644 Source/Cli/LatestVersion.cs create mode 100644 Source/Cli/LatestVersionSource.cs 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_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..0ee325d 100644 --- a/Source/Cli/Commands/Version/SelfUpdateCommand.cs +++ b/Source/Cli/Commands/Version/SelfUpdateCommand.cs @@ -36,7 +36,7 @@ protected override async Task ExecuteAsync(CommandContext context, SelfUpda { expectedNewVersion = await RunWithStatus( "Checking for updates...", - () => UpdateChecker.CheckForUpdate(UpdateChecker.CliPackageId, currentVersion, updateCheckCts.Token)); + () => UpdateChecker.CheckForUpdate(currentVersion, 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/UpdateChecker.cs b/Source/Cli/UpdateChecker.cs index 44a13e5..3a0ecdf 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 { /// @@ -43,13 +47,27 @@ 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. + /// + /// 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 Task CheckForUpdate(string currentVersion, CancellationToken cancellationToken = default) - => CheckForUpdate(CliPackageId, currentVersion, cancellationToken); + { + var source = LatestVersion.SourceFor(CliUpdate.DetectStrategy()); + return Check( + CacheKeyFor(source, CliPackageId), + currentVersion, + 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 +79,21 @@ 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, 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); - - return IsNewer(latestVersion, currentVersion) ? latestVersion : null; - } - catch - { - return null; - } - } + /// + /// 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}"; /// /// Determines whether the latest version is newer than the current version. @@ -111,39 +111,42 @@ 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, + 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 (cache?.Packages.TryGetValue(cacheKey, out var entry) == true && + DateTime.UtcNow - entry.CheckedAt < _checkInterval) { - 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() From b815b9d080edd8c1dd2a75b6e62be58a5d3d0562 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 30 Jul 2026 22:03:07 +0200 Subject: [PATCH 2/3] Re-check an up-to-date answer within the hour instead of the day The cached answer was trusted for 24 hours whatever it said. A check that lands in the window between a release being tagged and the package becoming visible records the version before it, so every release published afterwards went unannounced for a full day - which is what happened between 2.3.4 and 2.3.6, where a check ran 25 seconds before the package appeared. The two answers do not go stale at the same rate. "An update is available" stays true until the user installs it and is still held for a day, while "you are up to date" is only held for an hour. An answer that has fallen behind the installed version is not trusted at all, and asking for an update now always goes to the source, since that is the user saying the cached answer will not do. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UNSXxJz2S2SxP3piPPVK4K --- ...d_an_update_has_been_waiting_over_a_day.cs | 15 +++++++ .../and_an_update_is_waiting.cs | 18 ++++++++ .../and_the_up_to_date_answer_has_aged.cs | 20 +++++++++ .../and_the_up_to_date_answer_is_recent.cs | 15 +++++++ ...user_has_updated_past_the_cached_answer.cs | 18 ++++++++ .../Cli/Commands/Version/SelfUpdateCommand.cs | 4 +- Source/Cli/UpdateChecker.cs | 43 ++++++++++++++++--- 7 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_has_been_waiting_over_a_day.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_an_update_is_waiting.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_has_aged.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_up_to_date_answer_is_recent.cs create mode 100644 Source/Cli.Specs/for_UpdateChecker/when_checking_freshness/and_the_user_has_updated_past_the_cached_answer.cs 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/Commands/Version/SelfUpdateCommand.cs b/Source/Cli/Commands/Version/SelfUpdateCommand.cs index 0ee325d..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(currentVersion, updateCheckCts.Token)); + () => UpdateChecker.CheckForCliUpdate(currentVersion, true, updateCheckCts.Token)); } catch { diff --git a/Source/Cli/UpdateChecker.cs b/Source/Cli/UpdateChecker.cs index 3a0ecdf..ec5b8ab 100644 --- a/Source/Cli/UpdateChecker.cs +++ b/Source/Cli/UpdateChecker.cs @@ -29,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 }; /// @@ -52,17 +53,28 @@ public static string GetCachePath() => /// 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) + => 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 Task CheckForUpdate(string currentVersion, CancellationToken cancellationToken = default) + public static async Task CheckForCliUpdate(string currentVersion, bool bypassCache, CancellationToken cancellationToken = default) { var source = LatestVersion.SourceFor(CliUpdate.DetectStrategy()); - return Check( + return await Check( CacheKeyFor(source, CliPackageId), currentVersion, + bypassCache, token => source == LatestVersionSource.NuGet ? LatestVersion.FromNuGet(CliPackageId, token) : LatestVersion.FromGitHubRelease(token), @@ -80,7 +92,7 @@ public static string GetCachePath() => /// A cancellation token for timeout control. /// The latest version string if newer, otherwise null. public static Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default) => - Check(packageId, currentVersion, token => LatestVersion.FromNuGet(packageId, token), cancellationToken); + Check(packageId, currentVersion, false, token => LatestVersion.FromNuGet(packageId, token), cancellationToken); /// /// Gets the cache key an answer read from the given source is stored under. @@ -95,6 +107,23 @@ public static string GetCachePath() => internal static string CacheKeyFor(LatestVersionSource source, string packageId) => source == LatestVersionSource.NuGet ? packageId : $"github:{LatestVersion.GitHubRepository}"; + /// + /// 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. /// @@ -114,6 +143,7 @@ internal static bool IsNewer(string latest, string current) static async Task Check( string cacheKey, string currentVersion, + bool bypassCache, Func> fetch, CancellationToken cancellationToken) { @@ -123,8 +153,9 @@ internal static bool IsNewer(string latest, string current) } var cache = ReadCache(); - if (cache?.Packages.TryGetValue(cacheKey, out var entry) == true && - DateTime.UtcNow - entry.CheckedAt < _checkInterval) + if (!bypassCache && + cache?.Packages.TryGetValue(cacheKey, out var entry) == true && + IsFresh(entry.LatestVersion, entry.CheckedAt, currentVersion, DateTime.UtcNow)) { return IsNewer(entry.LatestVersion, currentVersion) ? entry.LatestVersion : null; } From 0a4c2006d2675a8009ba970ece43f2df1c9b9ad0 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 30 Jul 2026 22:03:40 +0200 Subject: [PATCH 3/3] Stop cancelling the update check on commands that outlast it The check was given a five second deadline measured from process start, so the time was spent while the command ran rather than on the request. Anything slower than that - the workbench, a run, generating a screenplay - cancelled the check before it finished, which meant no hint and, because the cache is only written once an answer arrives, no cached answer for the next run either. The request already carries its own five second timeout, so the deadline it needs is the one it has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UNSXxJz2S2SxP3piPPVK4K --- Source/Cli/Program.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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()) {