Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Homebrew upgrades from the tap, which the release workflow writes from a GitHub release - never NuGet.
/// </summary>
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);
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An update stays available until the user installs it, so this answer survives far longer.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The cached answer is now behind what is installed, so it says nothing about what comes next.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Keyed apart from the package so an answer read from NuGet is never served to a native installation.
/// </summary>
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);
}
4 changes: 3 additions & 1 deletion Source/Cli/Commands/Version/SelfUpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ protected override async Task<int> 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
{
Expand Down
5 changes: 3 additions & 2 deletions Source/Cli/Commands/Version/VersionCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ protected override async Task<int> 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<string?>(null);
Expand Down
121 changes: 121 additions & 0 deletions Source/Cli/LatestVersion.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reads the latest published version from the places a CLI installation can come from.
/// </summary>
public static class LatestVersion
{
/// <summary>
/// The GitHub repository the native downloads and the Homebrew formula are released from.
/// </summary>
public const string GitHubRepository = "Cratis/cli";

static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);

/// <summary>
/// Resolves the place an installation updating through the given strategy should be compared against.
/// </summary>
/// <param name="strategy">The detected update strategy.</param>
/// <returns>The <see cref="LatestVersionSource"/> to read from.</returns>
/// <remarks>
/// 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.
/// </remarks>
public static LatestVersionSource SourceFor(CliUpdateStrategy strategy) =>
strategy == CliUpdateStrategy.DotNetTool
? LatestVersionSource.NuGet
: LatestVersionSource.GitHubRelease;

/// <summary>
/// Reads the latest stable version of a package from NuGet.
/// </summary>
/// <param name="packageId">The NuGet package identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The latest stable version, or null when it could not be read.</returns>
public static async Task<string?> 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;
}

/// <summary>
/// Reads the version of the latest GitHub release.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The latest released version, or null when it could not be read.</returns>
public static async Task<string?> 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;
}

/// <summary>
/// Turns a release tag into a comparable version.
/// </summary>
/// <param name="tagName">The tag name, which the release workflow writes as <c>v{version}</c>.</param>
/// <returns>The version without the tag prefix, or null when there was nothing to read.</returns>
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;
}
}
25 changes: 25 additions & 0 deletions Source/Cli/LatestVersionSource.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents where the latest available version of the CLI should be read from.
/// </summary>
/// <remarks>
/// An installation has to be compared against the place it actually updates from. A tool installed with
/// <c>dotnet tool install</c> 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.
/// </remarks>
public enum LatestVersionSource
{
/// <summary>
/// Read the latest version from the NuGet package feed.
/// </summary>
NuGet,

/// <summary>
/// Read the latest version from the GitHub releases.
/// </summary>
GitHubRelease
}
Loading
Loading