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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteRelease?> 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<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride,
CancellationToken ct) { /* ... */ }
public Task DownloadAssetAsync(ReleaseAsset asset, Stream destination,
IProgress<DownloadProgress>? progress, CancellationToken ct) { /* ... */ }
}
Expand Down
32 changes: 20 additions & 12 deletions src/NextIteration.SpectreConsole.SelfUpdate/ISelfUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,29 @@ public interface ISelfUpdater
/// displayed and installed versions are guaranteed to match (no
/// TOCTOU window between display and install).
/// </summary>
Task<RemoteRelease?> GetLatestReleaseAsync(CancellationToken ct = default);
/// <param name="includePrereleasesOverride">
/// <see langword="null"/> defers to the configured
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/>;
/// <see langword="true"/>/<see langword="false"/> force inclusion or
/// exclusion for this call. Drives the <c>update --prerelease</c> CLI flag.
/// </param>
/// <param name="ct">Cancellation token.</param>
Task<RemoteRelease?> GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default);

/// <summary>
/// Per-invocation variant of
/// <see cref="GetLatestReleaseAsync(CancellationToken)"/> that lets the
/// caller override <see cref="SelfUpdaterOptions.IncludePrereleases"/>
/// for one call (used by the <c>update --prerelease</c> CLI flag).
/// <see langword="null"/> defers to the configured option;
/// <see langword="true"/>/<see langword="false"/> 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 <see langword="null"/> to
/// <see cref="GetLatestReleaseAsync(bool?, CancellationToken)"/>.
/// </summary>
Task<RemoteRelease?> GetLatestReleaseAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
GetLatestReleaseAsync(ct);
/// <remarks>
/// 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.
/// </remarks>
/// <param name="ct">Cancellation token.</param>
Task<RemoteRelease?> GetLatestReleaseAsync(CancellationToken ct = default) =>
GetLatestReleaseAsync(null, ct);

/// <summary>
/// Install the supplied release: download, run the verifier
Expand Down
33 changes: 21 additions & 12 deletions src/NextIteration.SpectreConsole.SelfUpdate/IUpdateChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,30 @@ public interface IUpdateChecker
/// returns an <see cref="UpdateInfo"/> with
/// <see cref="UpdateInfo.IsUpdateAvailable"/> populated.
/// </summary>
Task<UpdateInfo?> CheckAsync(CancellationToken ct = default);
/// <param name="includePrereleasesOverride">
/// <see langword="null"/> defers to the configured
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/>;
/// <see langword="true"/>/<see langword="false"/> force inclusion or
/// exclusion for this call. Drives the <c>update check --prerelease</c>
/// CLI flag.
/// </param>
/// <param name="ct">Cancellation token.</param>
Task<UpdateInfo?> CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default);

/// <summary>
/// Per-invocation variant of <see cref="CheckAsync(CancellationToken)"/>
/// that lets the caller override
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/> for one call
/// (used by the <c>update check --prerelease</c> CLI flag).
/// <see langword="null"/> defers to the configured option;
/// <see langword="true"/>/<see langword="false"/> 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 <see langword="null"/> to
/// <see cref="CheckAsync(bool?, CancellationToken)"/>.
/// </summary>
Task<UpdateInfo?> CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) =>
CheckAsync(ct);
/// <remarks>
/// 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.
/// </remarks>
/// <param name="ct">Cancellation token.</param>
Task<UpdateInfo?> CheckAsync(CancellationToken ct = default) =>
CheckAsync(null, ct);

/// <summary>
/// The running CLI's version, read from
Expand Down
40 changes: 23 additions & 17 deletions src/NextIteration.SpectreConsole.SelfUpdate/IUpdateSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,35 @@ public interface IUpdateSource
/// Optional channel filter. <see langword="null"/> means the source's
/// default channel (typically the latest non-prerelease tag).
/// </param>
/// <param name="ct">Cancellation token honoured for both DNS and stream reads.</param>
Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct);

/// <summary>
/// Per-invocation variant of <see cref="GetLatestAsync(string?, CancellationToken)"/>
/// that lets the caller override
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/> without
/// mutating shared options. Used by the <c>update --prerelease</c>
/// and <c>update check --prerelease</c> CLI flags.
/// </summary>
/// <param name="channel">Channel filter — see the base overload.</param>
/// <param name="includePrereleasesOverride">
/// <see langword="null"/> defers to the source's captured
/// <see cref="SelfUpdaterOptions.IncludePrereleases"/>; <see langword="true"/>
/// forces prerelease inclusion for this call; <see langword="false"/>
/// 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 <c>update --prerelease</c> and
/// <c>update check --prerelease</c> CLI flags. A source with no concept
/// of a prerelease may ignore it, but must say so in its own docs.
/// </param>
/// <param name="ct">Cancellation token honoured for both DNS and stream reads.</param>
Task<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct);

/// <summary>
/// Convenience overload that applies no prerelease override — equivalent
/// to passing <see langword="null"/> to
/// <see cref="GetLatestAsync(string?, bool?, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// 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 <c>--prerelease</c>.
/// </remarks>
/// <param name="channel">Channel filter — see the primary overload.</param>
/// <param name="ct">Cancellation token.</param>
Task<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) =>
GetLatestAsync(channel, ct);
Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct) =>
GetLatestAsync(channel, null, ct);

/// <summary>
/// Stream a single release asset to <paramref name="destination"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.SelfUpdate</PackageId>
<Version>0.3.1</Version>
<Version>1.0.0</Version>
<Description>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.</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
<PackageOutputPath>$(MSBuildThisFileDirectory)..\..\artifacts\packages</PackageOutputPath>
Expand All @@ -24,10 +24,10 @@
<PackageIcon>icon.png</PackageIcon>
<!--
Validate the public surface against the last shipped release, not just
framework compatibility: an accidental API break relative to 0.3.0
framework compatibility: an accidental API break relative to 0.3.1
fails the build instead of shipping.
-->
<PackageValidationBaselineVersion>0.3.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>0.3.1</PackageValidationBaselineVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,17 @@ private static bool IsHttps(Uri uri) =>
string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);

/// <inheritdoc />
/// <remarks>
/// The single-release manifest does not expose a prerelease flag, so the
/// <c>includePrereleasesOverride</c> overload simply delegates here — the
/// manifest's own tag is always returned regardless of override.
/// </remarks>
public Task<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct) =>
GetLatestAsync(channel, ct);
public Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct) =>
GetLatestAsync(channel, null, ct);

/// <inheritdoc />
public async Task<RemoteRelease?> GetLatestAsync(string? channel, CancellationToken ct)
/// <remarks>
/// A single-release manifest exposes no prerelease flag, so
/// <paramref name="includePrereleasesOverride"/> is deliberately ignored
/// here — the manifest's own tag is returned whatever the override says.
/// To serve prereleases separately, host one manifest per channel.
/// </remarks>
public async Task<RemoteRelease?> GetLatestAsync(string? channel, bool? includePrereleasesOverride, CancellationToken ct)
{
try
{
Expand Down
Loading