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) =>