From cc91e3d80a715a32b0a07904939e97f52704c726 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 21 Aug 2026 22:53:59 +0000 Subject: [PATCH] release: 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First stable release. Version 0.3.1 -> 1.0.0, PackageValidationBaselineVersion 0.3.0 -> 0.3.1 (it was stale — 0.3.1 shipped to nuget.org on 2026-08-19), and CHANGELOG [Unreleased] cut to [1.0.0] with the compare/tag link refs added. Breaking, for implementers only: the prerelease-override overloads on IUpdateSource, IUpdateChecker and ISelfUpdater are inverted. Before, the abstract member was the one WITHOUT the override, and the override-carrying member was a default interface implementation that discarded its argument. A custom IUpdateSource that implemented the abstract member — the only one it was obliged to implement — compiled, ran, and silently ignored `update --prerelease`. Now the override-carrying method is abstract and the no-override overload is the default implementation passing null. There is nothing left for an implementation to drop the override in. - IUpdateSource.GetLatestAsync(string?, bool?, CancellationToken) now abstract - IUpdateChecker.CheckAsync(bool?, CancellationToken) now abstract - ISelfUpdater.GetLatestReleaseAsync(bool?, CancellationToken) now abstract Callers are unaffected: both overloads still exist with identical signatures, so existing call sites compile and bind unchanged. Package validation does not flag it for the same reason — only which member carries the body moved, and that is invisible to an API-shape comparison. It passes against the 0.3.1 baseline. HttpManifestSource is flipped so its real logic sits on the override-carrying method; it still ignores the override, which is correct for a single-release manifest and is now documented on the method itself. The other two built-in sources and both pipeline types already had the right shape. InterfaceDefaultsTests implements each interface with only its abstract member and asserts the override arrives, so moving the abstract member back breaks the build. The README custom-source example carries the new signature. Release build: 0 warnings. 408 tests (204 × net8.0/net10.0) pass, up from 392. Package verified: 1.0.0, lib/net8.0 + lib/net10.0, per-TFM floors intact (net8 8.0.2/8.0.1, net10 10.0.11), license/icon/readme/repository present. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 39 ++++++++ README.md | 6 +- .../ISelfUpdater.cs | 32 ++++--- .../IUpdateChecker.cs | 33 ++++--- .../IUpdateSource.cs | 40 ++++---- ...Iteration.SpectreConsole.SelfUpdate.csproj | 6 +- .../Sources/HttpManifestSource.cs | 17 ++-- .../InterfaceDefaultsTests.cs | 96 +++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 2 +- 9 files changed, 217 insertions(+), 54 deletions(-) create mode 100644 tests/NextIteration.SpectreConsole.SelfUpdate.Tests/InterfaceDefaultsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e81c289..a1656d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +--- + +## [1.0.0] — 2026-08-21 + +First stable release. The public surface is now covered by Semantic Versioning: +a breaking change to it requires a 2.0.0. + +**Breaking, for implementers only.** `IUpdateSource`, `IUpdateChecker` and +`ISelfUpdater` each carried an overload pair in which the *abstract* member was +the one without the prerelease override, and the override-carrying member was a +default interface implementation that discarded its argument and delegated to +the abstract one. The consequence was silent: a custom `IUpdateSource` that +implemented the abstract member — the only one it was obliged to implement — +compiled cleanly, ran, and then ignored `update --prerelease` entirely. + +The relationship is now inverted. The override-carrying method is the abstract +member; the no-override overload is the default implementation, and it passes +`null`. An implementation can no longer drop the override without saying so, +because there is nothing left to drop it in. + +- `IUpdateSource.GetLatestAsync(string?, bool?, CancellationToken)` is now abstract; `GetLatestAsync(string?, CancellationToken)` is the default overload. +- `IUpdateChecker.CheckAsync(bool?, CancellationToken)` is now abstract; `CheckAsync(CancellationToken)` is the default overload. +- `ISelfUpdater.GetLatestReleaseAsync(bool?, CancellationToken)` is now abstract; `GetLatestReleaseAsync(CancellationToken)` is the default overload. + +**Who is affected:** only code that *implements* one of these three interfaces. +Code that merely *calls* them is unaffected — both overloads still exist with +identical signatures, so every existing call site still compiles and binds the +same way. Package validation therefore does **not** flag this: the API shape is +unchanged and only which member carries the body moved, which it cannot see. +The fix for an implementer is to add the `bool? includePrereleasesOverride` +parameter to the method it already has, and honour it — or document that the +source has no notion of a prerelease, as the built-in `HttpManifestSource` does. +The three built-in sources and both built-in pipeline types already implemented +the override-carrying method, so none of them changed behaviour. + ### Changed +- **The prerelease-override overloads were inverted on all three interfaces** — see the breaking-change note above. A regression test (`InterfaceDefaultsTests`) implements each interface with *only* its abstract member and asserts the override reaches it, so if the abstract member ever moves back to the no-override overload the test project stops compiling. + - **Adopted the revised canonical `.editorconfig` and enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §5.2, §1.2.1 — the latter now a `MUST`). The canonical file is a deliberate allow-list of gated style rules rather than a blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a future SDK ships never auto-gates the build. With the flag on, the gated rules fail the build under `TreatWarningsAsErrors` instead of merely showing in the IDE. Bringing the code green was a mechanical, behaviour-preserving reformat of 92 sites — braces on all single-statement `if`s (IDE0011, 64 of them), collection expressions (IDE0300/IDE0301/IDE0028), `var` usage, two expression-bodied members, one simplified null check, and five unnecessary usings — applied with `dotnet format` plus the collection-expression sites it cannot fix automatically. All 392 tests (196 × `net8.0`/`net10.0`) pass unchanged, and the build stays at zero warnings. - **`Path.Combine` → `Path.Join` repo-wide** (134 call sites, `src` and `tests`). `Path.Combine` returns its *last rooted argument* and silently discards everything before it, so a rooted second segment escapes the directory the first argument names. `Path.Join` always concatenates. Nothing here was reachable with a rooted segment — `ValidateAssetName` already rejects rooted and separator-bearing asset names before any path is built — so this is defence in depth on the install-directory path construction rather than a fix for a live defect. Verified behaviour-preserving: all 392 tests pass unchanged. - **Idiomatic LINQ in place of filter-style loops.** `DefaultAssetResolver`'s five matcher loops become `FirstOrDefault`/`Any` predicates (the RID candidate walk stays lazy — `Select` plus `FirstOrDefault` still resolves a later candidate only when the earlier ones miss), `Sha256SumsManifest.Parse` maps its lines with `Select`, and `UpdateInstaller.IsPreserved` filters with `Where`/`Any` behind a named `MatchesTopLevelSegment` helper. @@ -224,6 +261,8 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the - Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. - SourceLink, deterministic builds, published symbol packages. +[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v1.0.0 [0.3.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.3.1 [0.3.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.3.0 [0.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.2.0 diff --git a/README.md b/README.md index 85e1c30..b3e2d24 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,11 @@ When `sha256` is populated, the default verifier picks it up via `ReleaseAsset.M ```csharp public sealed class MyArtifactRegistrySource : IUpdateSource { - public Task GetLatestAsync(string? channel, CancellationToken ct) { /* ... */ } + // `includePrereleasesOverride` carries the `update --prerelease` flag: + // null defers to the configured option, true/false force it for this call. + // A source with no notion of a prerelease may ignore it — say so in its docs. + public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, + CancellationToken ct) { /* ... */ } public Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IProgress? progress, CancellationToken ct) { /* ... */ } } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs index 6d8eaac..a1e7f58 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs @@ -24,21 +24,29 @@ public interface ISelfUpdater /// displayed and installed versions are guaranteed to match (no /// TOCTOU window between display and install). /// - Task GetLatestReleaseAsync(CancellationToken ct = default); + /// + /// defers to the configured + /// ; + /// / force inclusion or + /// exclusion for this call. Drives the update --prerelease CLI flag. + /// + /// Cancellation token. + Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default); /// - /// Per-invocation variant of - /// that lets the - /// caller override - /// for one call (used by the update --prerelease CLI flag). - /// defers to the configured option; - /// / force inclusion or - /// exclusion. The default-interface implementation drops the override - /// and delegates to the base overload so existing custom updaters - /// continue to compile. + /// Convenience overload that applies no prerelease override — equivalent + /// to passing to + /// . /// - Task GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) => - GetLatestReleaseAsync(ct); + /// + /// A default interface implementation delegating to the + /// override-carrying method, so an updater implements one of the two and + /// cannot silently ignore the override. Before 1.0.0 this relationship + /// ran the other way round. + /// + /// Cancellation token. + Task GetLatestReleaseAsync(CancellationToken ct = default) => + GetLatestReleaseAsync(null, ct); /// /// Install the supplied release: download, run the verifier diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs index 68c2c47..58aee22 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs @@ -16,21 +16,30 @@ public interface IUpdateChecker /// returns an with /// populated. /// - Task CheckAsync(CancellationToken ct = default); + /// + /// defers to the configured + /// ; + /// / force inclusion or + /// exclusion for this call. Drives the update check --prerelease + /// CLI flag. + /// + /// Cancellation token. + Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default); /// - /// Per-invocation variant of - /// that lets the caller override - /// for one call - /// (used by the update check --prerelease CLI flag). - /// defers to the configured option; - /// / force inclusion or - /// exclusion. The default-interface implementation drops the override - /// and delegates to the base overload so existing custom checkers - /// continue to compile. + /// Convenience overload that applies no prerelease override — equivalent + /// to passing to + /// . /// - Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) => - CheckAsync(ct); + /// + /// A default interface implementation delegating to the + /// override-carrying method, so a checker implements one of the two and + /// cannot silently ignore the override. Before 1.0.0 this relationship + /// ran the other way round. + /// + /// Cancellation token. + Task CheckAsync(CancellationToken ct = default) => + CheckAsync(null, ct); /// /// The running CLI's version, read from diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs index 3b5d5bf..8036228 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs @@ -25,29 +25,35 @@ public interface IUpdateSource /// Optional channel filter. means the source's /// default channel (typically the latest non-prerelease tag). /// - /// Cancellation token honoured for both DNS and stream reads. - Task GetLatestAsync(string? channel, CancellationToken ct); - - /// - /// Per-invocation variant of - /// that lets the caller override - /// without - /// mutating shared options. Used by the update --prerelease - /// and update check --prerelease CLI flags. - /// - /// Channel filter — see the base overload. /// /// defers to the source's captured /// ; /// forces prerelease inclusion for this call; - /// forces exclusion. The default interface implementation drops the - /// override and delegates to the base overload so existing third-party - /// sources continue to compile — implementers wanting to honour the - /// CLI flag should override this method explicitly. + /// forces exclusion. Drives the update --prerelease and + /// update check --prerelease CLI flags. A source with no concept + /// of a prerelease may ignore it, but must say so in its own docs. /// + /// Cancellation token honoured for both DNS and stream reads. + Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct); + + /// + /// Convenience overload that applies no prerelease override — equivalent + /// to passing to + /// . + /// + /// + /// This is a default interface implementation and delegates to the + /// override-carrying method above, so a source only ever implements one + /// of the two and cannot silently ignore the override. Before 1.0.0 the + /// relationship ran the other way: the no-override method was the + /// abstract one and the override-carrying method defaulted to discarding + /// its argument, so a source that implemented only the abstract member + /// compiled cleanly and then silently ignored --prerelease. + /// + /// Channel filter — see the primary overload. /// Cancellation token. - Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) => - GetLatestAsync(channel, ct); + Task GetLatestAsync(string? channel, CancellationToken ct) => + GetLatestAsync(channel, null, ct); /// /// Stream a single release asset to . diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index 201b3fb..7375705 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj +++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj @@ -11,7 +11,7 @@ NextIteration.SpectreConsole.SelfUpdate - 0.3.1 + 1.0.0 Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command. true $(MSBuildThisFileDirectory)..\..\artifacts\packages @@ -24,10 +24,10 @@ icon.png - 0.3.0 + 0.3.1 diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs index 36a02af..6c6f935 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs @@ -95,16 +95,17 @@ private static bool IsHttps(Uri uri) => string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); /// - /// - /// The single-release manifest does not expose a prerelease flag, so the - /// includePrereleasesOverride overload simply delegates here — the - /// manifest's own tag is always returned regardless of override. - /// - public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) => - GetLatestAsync(channel, ct); + public Task GetLatestAsync(string? channel, CancellationToken ct) => + GetLatestAsync(channel, null, ct); /// - public async Task GetLatestAsync(string? channel, CancellationToken ct) + /// + /// A single-release manifest exposes no prerelease flag, so + /// is deliberately ignored + /// here — the manifest's own tag is returned whatever the override says. + /// To serve prereleases separately, host one manifest per channel. + /// + public async Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) { try { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/InterfaceDefaultsTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/InterfaceDefaultsTests.cs new file mode 100644 index 0000000..9481048 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/InterfaceDefaultsTests.cs @@ -0,0 +1,96 @@ +using Xunit; + +namespace NextIteration.SpectreConsole.SelfUpdate.Tests +{ + /// + /// Locks in the 1.0.0 interface shape: the override-carrying method is the + /// abstract one, and the convenience overload is the default interface + /// implementation that delegates to it. Before 1.0.0 the relationship ran + /// the other way, so an implementation that supplied only the abstract + /// member compiled cleanly and then silently discarded the caller's + /// prerelease override. + /// + public sealed class InterfaceDefaultsTests + { + // Implements ONLY the abstract member of each interface. If the + // override-carrying method ever stops being the abstract one, this type + // fails to compile — which is the guarantee, not the assertions below. + private sealed class MinimalSource : IUpdateSource + { + public bool? LastOverride { get; private set; } + public int Calls { get; private set; } + + public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) + { + LastOverride = includePrereleasesOverride; + Calls++; + return Task.FromResult(null); + } + + public Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IProgress? progress, CancellationToken ct) => + Task.CompletedTask; + } + + private sealed class MinimalChecker : IUpdateChecker + { + public bool? LastOverride { get; private set; } + + public Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) + { + LastOverride = includePrereleasesOverride; + return Task.FromResult(null); + } + + public string? GetCurrentVersion() => "1.0.0"; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task Source_override_reaches_a_minimal_implementation(bool? requested) + { + var source = new MinimalSource(); + + await ((IUpdateSource)source).GetLatestAsync("stable", requested, TestContext.Current.CancellationToken); + + Assert.Equal(requested, source.LastOverride); + } + + [Fact] + public async Task Source_convenience_overload_passes_null_and_still_dispatches() + { + var source = new MinimalSource(); + + // The two-argument overload is the default interface implementation, + // so it must route through the single abstract member. + await ((IUpdateSource)source).GetLatestAsync("stable", TestContext.Current.CancellationToken); + + Assert.Equal(1, source.Calls); + Assert.Null(source.LastOverride); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task Checker_override_reaches_a_minimal_implementation(bool? requested) + { + var checker = new MinimalChecker(); + + await ((IUpdateChecker)checker).CheckAsync(requested, TestContext.Current.CancellationToken); + + Assert.Equal(requested, checker.LastOverride); + } + + [Fact] + public async Task Checker_convenience_overload_passes_null() + { + var checker = new MinimalChecker(); + + await ((IUpdateChecker)checker).CheckAsync(TestContext.Current.CancellationToken); + + Assert.Null(checker.LastOverride); + } + } +} diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/ServiceCollectionExtensionsTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/ServiceCollectionExtensionsTests.cs index 0f79402..1535fcf 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/ServiceCollectionExtensionsTests.cs @@ -131,7 +131,7 @@ public Task VerifyAsync(string downloadedFilePath, RemoteRelease release, Releas private sealed class StubSource : IUpdateSource { - public Task GetLatestAsync(string? channel, CancellationToken ct) => + public Task GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) => Task.FromResult(null); public Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IProgress? progress, CancellationToken ct) =>