From 06aa71571dd49c01a193a7d52dd4091de7ddccb7 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:03:21 +0800 Subject: [PATCH] feat: tell people when a new version is out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two releases shipped today, each fixing something the previous one got wrong — and anyone still on v0.2.0 has a key validation that accepts any string and an app that crashes on piped input, with no way to find out either is fixed. Shipping fixes nobody learns about is most of the way to not shipping them. One request at launch to the GitHub releases API, compared numerically so 0.10.0 reads as newer than 0.9.0. Failure of any kind — offline, rate limited, captive portal, nonsense response — produces silence rather than an error, because a failed update check is not worth a word on screen. Notify only. Nothing is downloaded or installed automatically, in any phase. A tool that replaces its own binary is a tool people are right to distrust, and it fights the "one file you can copy anywhere" model. Shown in grey at the prompt rather than yellow mid-reply: a version being available is information, not a warning, and never urgent enough to interrupt a reply. The check itself is not awaited, so it cannot delay the app becoming usable. The honest part. This project's stated rule is no telemetry and no phone-home, and an update check sits close enough to that line to deserve care rather than a shrug. It sends no identifier, no key and no usage data — an unauthenticated GET of a public page — but it does reveal that someone at an IP launched OpenKey. So it is declared plainly in SECURITY.md with what it does and does not send, documented in the user guide, and switchable off with checkForUpdates in config.json, after which OpenKey talks to OpenRouter and nowhere else. config.json gains that field, so docs/05 is updated too. 146 tests, including that an unparseable version never counts as newer — a bad comparison must not nag someone about an update that isn't real. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- CHANGELOG.md | 7 + SECURITY.md | 29 ++++- docs/05-persistence-and-reset.md | 8 +- docs/07-roadmap.md | 6 +- docs/08-user-guide.md | 6 +- src/OpenKey.Core/Storage/IConfigStore.cs | 10 +- src/OpenKey.Core/Storage/JsonConfigStore.cs | 2 +- src/OpenKey.Core/Updates/UpdateChecker.cs | 109 ++++++++++++++++ src/OpenKey.Gui/Program.cs | 3 + .../ViewModels/MainWindowViewModel.cs | 17 +++ src/OpenKey/ConsoleHost.cs | 35 +++++ src/OpenKey/Program.cs | 2 + .../OpenKey.Core.Tests/UpdateCheckerTests.cs | 123 ++++++++++++++++++ 13 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/OpenKey.Core/Updates/UpdateChecker.cs create mode 100644 tests/OpenKey.Core.Tests/UpdateCheckerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 719b362..07c56ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **OpenKey tells you when a new version is out.** It checks once at launch and shows a quiet line + with the link — nothing is ever downloaded or installed for you. Turn it off with + `"checkForUpdates": false` in `config.json`; see `SECURITY.md` for exactly what the check sends, + which is nothing beyond the request itself. + ## [0.3.0] — 2026-08-03 ### Added diff --git a/SECURITY.md b/SECURITY.md index 506e1a2..571b609 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,11 +14,34 @@ Include what you did, what happened, and what you expected. A proof of concept h decrypt it. Copying the file to another PC or another user account yields nothing usable. - **Your conversations** are stored in plain JSON at `%APPDATA%\OpenKey\session.json`. They are not encrypted. Anyone with access to your Windows account can read them. `/reset` deletes them. -- **Network traffic** goes to `openrouter.ai` and nowhere else. The only other connection OpenKey - ever opens is a local `http://localhost:3000/callback` listener, briefly, during browser sign-in. -- **No telemetry.** No analytics, no crash reporting, no phone-home, in any phase. This is a +- **Network traffic** goes to `openrouter.ai`, plus one request to `api.github.com` at launch to + ask whether a newer release exists. The only other connection OpenKey opens is a local + `http://localhost:3000/callback` listener, briefly, during browser sign-in. +- **No telemetry.** No analytics, no crash reporting, no usage data, in any phase. This is a standing project rule, not a current default. +### About the update check + +It is worth being precise, because "checks for updates" and "phones home" can look alike. + +The check is an unauthenticated `GET` of a public page — the same URL a browser would open — and +sends no identifier, no key, no version history and no usage data. It cannot be correlated with an +account because no account is involved. + +It does, however, reveal to GitHub that *someone at your IP launched OpenKey*. That is a real +disclosure, small but not nothing, so it is declared here rather than buried, and you can switch it +off: + +```json +{ "checkForUpdates": false } +``` + +in `%APPDATA%\OpenKey\config.json`. + +**Nothing is ever downloaded or installed automatically.** OpenKey tells you a version exists and +gives you the link. A tool that replaces its own binary is a tool you are right to distrust, and no +phase of this project will add one. + ## Threat model OpenKey is a single-user desktop application. It assumes the Windows account it runs under is diff --git a/docs/05-persistence-and-reset.md b/docs/05-persistence-and-reset.md index 159e4b8..1070cd6 100644 --- a/docs/05-persistence-and-reset.md +++ b/docs/05-persistence-and-reset.md @@ -132,10 +132,16 @@ See `04-model-rotation.md` § "Persistence of rotation state". { "preferredModels": [], "theme": "default", - "maxTokens": 2048 + "maxTokens": 2048, + "checkForUpdates": true } ``` +`checkForUpdates` governs the single request to `api.github.com` made at launch to see whether a +newer release exists. Notify only — nothing is downloaded or installed automatically, in any phase. +Set it to `false` and OpenKey talks to OpenRouter and nowhere else. See +[`../SECURITY.md`](../SECURITY.md). + If absent, defaults apply (empty list means rotation chooses freely). Owned by `IConfigStore` / `JsonConfigStore`. `preferredModels` is how a pinned model is expressed — diff --git a/docs/07-roadmap.md b/docs/07-roadmap.md index cdb9cf3..4a64744 100644 --- a/docs/07-roadmap.md +++ b/docs/07-roadmap.md @@ -48,7 +48,11 @@ Features that touch storage or DI but stay backward-compatible. - **Token counter** — estimate tokens per turn and show running total in status line. Use a real tokenizer NuGet (e.g., `Tiktoken` for OpenAI-family models, `MicrosoftDeepDev.Tokenizer` for cross-model). - **`/config`** — interactive Spectre menu to edit `config.json` (preferred model order, max_tokens, theme). -- **Update checker** — on launch, query `https://api.github.com/repos/corecompiled/OpenKey/releases/latest`. If newer, Spectre yellow notice with download URL. **Do not auto-download.** +- ~~**Update checker**~~ — done. Grey rather than yellow: a version being available is + information, not a warning. Shown at the prompt rather than mid-reply, and switchable off via + `checkForUpdates` in `config.json` — it is the only request OpenKey makes outside OpenRouter, so + it is declared in [`../SECURITY.md`](../SECURITY.md) rather than left implicit. Still never + downloads anything. - **Theme toggle** — `/theme dark|light|mono`. Stored in `config.json`. Spectre styles parameterized. - **Multi-key support**: - `/key add ` — add another OpenRouter key under a label diff --git a/docs/08-user-guide.md b/docs/08-user-guide.md index 4f7a5e7..024d3fc 100644 --- a/docs/08-user-guide.md +++ b/docs/08-user-guide.md @@ -114,7 +114,11 @@ Your key is encrypted so that only your Windows account on this PC can read it to another machine gets someone nothing. **Your conversation is not encrypted**, so anyone who can use your Windows account can read it. -OpenKey talks to OpenRouter and nowhere else. No analytics, no tracking, ever. +OpenKey talks to OpenRouter, and once at launch asks GitHub whether a newer version exists. That +check sends nothing about you and never downloads anything — it just shows a link. Turn it off by +putting `"checkForUpdates": false` in `config.json`. + +No analytics, no tracking, ever. ## When something goes wrong diff --git a/src/OpenKey.Core/Storage/IConfigStore.cs b/src/OpenKey.Core/Storage/IConfigStore.cs index fd70009..ee30127 100644 --- a/src/OpenKey.Core/Storage/IConfigStore.cs +++ b/src/OpenKey.Core/Storage/IConfigStore.cs @@ -10,16 +10,22 @@ namespace OpenKey.Core.Storage; /// /// Palette name: default, dark, light, or mono. /// Upper bound on reply length requested from the model. +/// +/// Whether to ask GitHub once at launch if a newer release exists. Notify only — nothing is ever +/// downloaded or installed automatically. This is the only request OpenKey makes to anywhere other +/// than OpenRouter, so it is declared here and can be switched off. +/// public sealed record OpenKeyConfig( IReadOnlyList PreferredModels, string Theme, - int MaxTokens) + int MaxTokens, + bool CheckForUpdates = true) { public const string DefaultTheme = "default"; public const int DefaultMaxTokens = 2048; public static OpenKeyConfig Default { get; } = - new(Array.Empty(), DefaultTheme, DefaultMaxTokens); + new(Array.Empty(), DefaultTheme, DefaultMaxTokens, CheckForUpdates: true); /// /// The pinned model, or null when rotation is free to choose. A view over diff --git a/src/OpenKey.Core/Storage/JsonConfigStore.cs b/src/OpenKey.Core/Storage/JsonConfigStore.cs index e9f1ae5..1155746 100644 --- a/src/OpenKey.Core/Storage/JsonConfigStore.cs +++ b/src/OpenKey.Core/Storage/JsonConfigStore.cs @@ -81,6 +81,6 @@ private static OpenKeyConfig Normalize(OpenKeyConfig? config) ? config.MaxTokens : OpenKeyConfig.DefaultMaxTokens; - return new OpenKeyConfig(models, theme, maxTokens); + return new OpenKeyConfig(models, theme, maxTokens, config.CheckForUpdates); } } diff --git a/src/OpenKey.Core/Updates/UpdateChecker.cs b/src/OpenKey.Core/Updates/UpdateChecker.cs new file mode 100644 index 0000000..7e65da7 --- /dev/null +++ b/src/OpenKey.Core/Updates/UpdateChecker.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace OpenKey.Core.Updates; + +/// A newer release than the one running. +public sealed record UpdateInfo(string Version, string Url); + +public interface IUpdateChecker +{ + /// + /// The newest release if it is newer than , otherwise null. + /// Never throws: a failed check is not worth a word on screen, let alone an error. + /// + Task CheckAsync(string currentVersion, CancellationToken ct); +} + +/// +/// Asks GitHub whether a newer release exists. +/// +/// Notify only — OpenKey never downloads or installs anything by itself. A tool that +/// replaces its own binary is a tool people are right to distrust, and it fights the "one file you +/// can copy anywhere" model. That is a standing project rule, not a default. +/// +/// +/// This is the only request OpenKey makes to anywhere other than OpenRouter, which is why it is +/// switchable off in config.json and documented in SECURITY.md. It sends no identifiers and +/// no usage data — it is an unauthenticated GET of a public page, the same one a browser would +/// fetch. But it does reveal that someone launched the app, so declaring it plainly and letting +/// people decline is the honest treatment. +/// +/// +public sealed class GitHubUpdateChecker : IUpdateChecker +{ + private const string LatestReleaseUrl = + "https://api.github.com/repos/corecompiled/OpenKey/releases/latest"; + + /// Short: this runs at launch and must never delay the app becoming usable. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly HttpClient _http; + + public GitHubUpdateChecker(HttpClient http) => _http = http; + + public async Task CheckAsync(string currentVersion, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, LatestReleaseUrl); + req.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json"); + req.Headers.TryAddWithoutValidation("User-Agent", "OpenKey"); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(Timeout); + + using var resp = await _http.SendAsync(req, cts.Token); + if (!resp.IsSuccessStatusCode) return null; + + var release = await resp.Content.ReadFromJsonAsync( + UpdateJsonContext.Default.GitHubRelease, cts.Token); + + if (release?.TagName is not { Length: > 0 } tag) return null; + if (release.Draft || release.Prerelease) return null; + + return IsNewer(tag, currentVersion) + ? new UpdateInfo(Normalize(tag), release.HtmlUrl ?? "https://github.com/corecompiled/OpenKey/releases") + : null; + } + catch (Exception) + { + // Offline, rate-limited, captive portal, malformed response — none of it matters + // enough to tell the user about. Silence is the correct outcome. + return null; + } + } + + internal static string Normalize(string version) => + version.TrimStart('v', 'V').Split('-', '+')[0].Trim(); + + /// + /// Compares release numbers, not strings: "0.10.0" is newer than "0.9.0" even though it sorts + /// earlier alphabetically. Anything unparseable is treated as "not newer" — a bad comparison + /// should never nag someone about an update that isn't real. + /// + internal static bool IsNewer(string candidate, string current) + { + if (!Version.TryParse(Pad(Normalize(candidate)), out var a)) return false; + if (!Version.TryParse(Pad(Normalize(current)), out var b)) return false; + return a > b; + } + + // Version.TryParse rejects a bare "1"; pad to at least major.minor. + private static string Pad(string v) => v.Count(c => c == '.') switch + { + 0 => v + ".0.0", + 1 => v + ".0", + _ => v, + }; +} + +internal sealed record GitHubRelease( + [property: JsonPropertyName("tag_name")] string? TagName, + [property: JsonPropertyName("html_url")] string? HtmlUrl, + [property: JsonPropertyName("draft")] bool Draft, + [property: JsonPropertyName("prerelease")] bool Prerelease); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(GitHubRelease))] +internal sealed partial class UpdateJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/src/OpenKey.Gui/Program.cs b/src/OpenKey.Gui/Program.cs index e024b44..1180d78 100644 --- a/src/OpenKey.Gui/Program.cs +++ b/src/OpenKey.Gui/Program.cs @@ -4,6 +4,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Gui.ViewModels; using OpenKey.Providers.OpenRouter; using OpenKey.Windows; @@ -28,6 +29,8 @@ public static int Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs index 916d7e2..4933fc8 100644 --- a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs +++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Providers.OpenRouter; using OpenKey.Windows.OAuth; @@ -18,6 +19,7 @@ public sealed class MainWindowViewModel : ObservableObject private readonly IModelCatalog _catalog; private readonly IRotationPolicy _rotation; private readonly IConfigStore _config; + private readonly IUpdateChecker _updates; private readonly IAppPaths _paths; private readonly HttpClient _http; @@ -36,6 +38,7 @@ public MainWindowViewModel( IModelCatalog catalog, IRotationPolicy rotation, IConfigStore config, + IUpdateChecker updates, IAppPaths paths, HttpClient http) { @@ -44,6 +47,7 @@ public MainWindowViewModel( _catalog = catalog; _rotation = rotation; _config = config; + _updates = updates; _paths = paths; _http = http; @@ -163,6 +167,19 @@ public async Task InitializeAsync() } await LoadModelsAsync(); + + // Not awaited: the window is usable immediately, and a new version is never urgent. + _ = CheckForUpdateAsync(); + } + + private async Task CheckForUpdateAsync() + { + if (!_config.Current.CheckForUpdates) return; + + var found = await _updates.CheckAsync(Version, CancellationToken.None); + if (found is null) return; + + Show(StatusKind.Info, $"OpenKey {found.Version} is available — {found.Url}"); } public async Task LoadModelsAsync() diff --git a/src/OpenKey/ConsoleHost.cs b/src/OpenKey/ConsoleHost.cs index 1c1f82e..b6b64bf 100644 --- a/src/OpenKey/ConsoleHost.cs +++ b/src/OpenKey/ConsoleHost.cs @@ -4,6 +4,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Windows; using OpenKey.Windows.OAuth; using OpenKey.Providers.OpenRouter; @@ -26,6 +27,7 @@ public sealed class ConsoleHost private readonly IRotationPolicy _rotation; private readonly ChatEngine _engine; private readonly IConfigStore _config; + private readonly IUpdateChecker _updates; private readonly HttpClient _http; private CommandRouter _commands = default!; @@ -44,6 +46,7 @@ public ConsoleHost( IRotationPolicy rotation, ChatEngine engine, IConfigStore config, + IUpdateChecker updates, HttpClient http) { _paths = paths; @@ -53,6 +56,7 @@ public ConsoleHost( _rotation = rotation; _engine = engine; _config = config; + _updates = updates; _http = http; } @@ -94,6 +98,10 @@ public async Task RunAsync() await _engine.ResumeAsync(CancellationToken.None); ShowResumeRecapIfAny(); + // Deliberately not awaited: an update check must never stand between launching and typing. + // It prints only if it finds something, and only between turns. + _ = CheckForUpdateAsync(); + while (!_exiting) { string? line; @@ -110,6 +118,8 @@ public async Task RunAsync() if (_exiting) break; if (string.IsNullOrWhiteSpace(line)) continue; + ShowUpdateNoticeIfAny(); + var result = await _commands.HandleAsync(line, CancellationToken.None); if (result == CommandResult.Exit) break; if (result == CommandResult.Handled) @@ -283,6 +293,31 @@ private static void ShowChatError(ChatException ex) private static void ClearAndShowChatHeader() => Components.HomeHeader(); + private UpdateInfo? _pendingUpdate; + + private async Task CheckForUpdateAsync() + { + if (!_config.Current.CheckForUpdates) return; + + var found = await _updates.CheckAsync(Components.Version, CancellationToken.None); + if (found is not null) _pendingUpdate = found; + } + + /// + /// Shown once, at the prompt, never mid-reply. A new version is worth mentioning; it is not + /// worth interrupting anything for. + /// + private void ShowUpdateNoticeIfAny() + { + if (_pendingUpdate is not { } update) return; + _pendingUpdate = null; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[{Theme.Muted}]OpenKey {Markup.Escape(update.Version)} is available.[/] {Markup.Escape(update.Url)}"); + Components.HintLine("Nothing is downloaded automatically. Turn this off with checkForUpdates in config.json."); + } + /// /// Past turns are rendered entirely grey and indented, so the resumed history reads as inert /// rather than as part of the live conversation. diff --git a/src/OpenKey/Program.cs b/src/OpenKey/Program.cs index 86e071d..a60e5db 100644 --- a/src/OpenKey/Program.cs +++ b/src/OpenKey/Program.cs @@ -6,6 +6,7 @@ using OpenKey.Core.Engine; using OpenKey.Core.Providers; using OpenKey.Core.Storage; +using OpenKey.Core.Updates; using OpenKey.Providers.OpenRouter; // LLM replies contain non-ASCII (em-dash, smart quotes, emoji). Without UTF-8 the @@ -33,6 +34,7 @@ services.AddSingleton(); services.AddSingleton(); +services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs b/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs new file mode 100644 index 0000000..c114e87 --- /dev/null +++ b/tests/OpenKey.Core.Tests/UpdateCheckerTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Text; +using OpenKey.Core.Updates; +using Xunit; + +namespace OpenKey.Core.Tests; + +public sealed class UpdateCheckerTests +{ + private sealed class StubHandler : HttpMessageHandler + { + private readonly HttpStatusCode _status; + private readonly string _body; + private readonly Exception? _throw; + + public StubHandler(string body, HttpStatusCode status = HttpStatusCode.OK, Exception? toThrow = null) + { + _body = body; + _status = status; + _throw = toThrow; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (_throw is not null) throw _throw; + return Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_body, Encoding.UTF8, "application/json"), + }); + } + } + + private static GitHubUpdateChecker Checker(string body, HttpStatusCode status = HttpStatusCode.OK, Exception? toThrow = null) => + new(new HttpClient(new StubHandler(body, status, toThrow))); + + private static string Release(string tag, bool draft = false, bool prerelease = false) => + $$"""{"tag_name":"{{tag}}","html_url":"https://example.com/{{tag}}","draft":{{(draft ? "true" : "false")}},"prerelease":{{(prerelease ? "true" : "false")}}}"""; + + [Fact] + public async Task ReportsANewerRelease() + { + var found = await Checker(Release("v0.4.0")).CheckAsync("0.3.0", CancellationToken.None); + + Assert.NotNull(found); + Assert.Equal("0.4.0", found!.Version); + } + + [Fact] + public async Task SaysNothingWhenAlreadyCurrent() + { + Assert.Null(await Checker(Release("v0.3.0")).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task SaysNothingWhenRunningAheadOfTheRelease() + { + Assert.Null(await Checker(Release("v0.2.1")).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task IgnoresDraftsAndPrereleases() + { + Assert.Null(await Checker(Release("v9.9.9", draft: true)).CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker(Release("v9.9.9", prerelease: true)).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Forbidden)] // rate limited + [InlineData(HttpStatusCode.InternalServerError)] + public async Task StaysSilentOnAnyHttpFailure(HttpStatusCode status) + { + Assert.Null(await Checker("{}", status).CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task StaysSilentWhenOffline() + { + // A failed check is not worth a word on screen, let alone an error card. + var checker = Checker("", toThrow: new HttpRequestException("no network")); + + Assert.Null(await checker.CheckAsync("0.3.0", CancellationToken.None)); + } + + [Fact] + public async Task StaysSilentOnNonsenseResponses() + { + Assert.Null(await Checker("not json").CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker("""{"tag_name":""}""").CheckAsync("0.3.0", CancellationToken.None)); + Assert.Null(await Checker("""{"tag_name":"banana"}""").CheckAsync("0.3.0", CancellationToken.None)); + } + + [Theory] + [InlineData("0.10.0", "0.9.0", true)] // numeric, not alphabetical + [InlineData("0.9.0", "0.10.0", false)] + [InlineData("1.0.0", "0.99.99", true)] + [InlineData("v0.4.0", "0.4.0", false)] + // Prerelease suffixes are stripped, so 0.4.0 and 0.4.0-beta compare equal rather than the + // former being treated as newer. OpenKey never publishes prereleases, so the case cannot + // arise in practice; full semver ordering would be complexity with no caller. + [InlineData("0.4.0", "0.4.0-beta", false)] + public void ComparesReleaseNumbersNotStrings(string candidate, string current, bool expected) + { + Assert.Equal(expected, GitHubUpdateChecker.IsNewer(candidate, current)); + } + + [Fact] + public void AnUnparseableVersionNeverCountsAsNewer() + { + // A bad comparison must not nag someone about an update that isn't real. + Assert.False(GitHubUpdateChecker.IsNewer("not-a-version", "0.3.0")); + Assert.False(GitHubUpdateChecker.IsNewer("0.4.0", "not-a-version")); + } + + [Theory] + [InlineData("v1.2.3", "1.2.3")] + [InlineData("1.2.3+abc123", "1.2.3")] + [InlineData("V1.2.3-beta", "1.2.3")] + public void TagsAreNormalisedBeforeComparing(string tag, string expected) + { + Assert.Equal(expected, GitHubUpdateChecker.Normalize(tag)); + } +}