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
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.2.0] — 2026-10-20

Fixes the post-run update banner repeating what `update` / `update check` just said — most
visibly, offering the version that had just been installed — and adds
`UpdateBanner.Suppress()` for apps with their own update UX. No breaking changes.

### Fixed

- **The post-run banner no longer repeats what `update` or `update check` just said** ([#42](https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/issues/42)).
Before, an app following the README got the banner even after `update` had run. Right
after a successful install, it told the operator to install the version they had just
installed. The banner compares against the running process's version, and that is
still the old binary after the swap. `UpdateBanner.RenderIfAvailable` now renders nothing:
- once the built-in `update` or `update check` command has shown the latest release,
whether the operator installed it, declined, or the install failed;
- when this process has already installed a release at least as new as the one
available, so an app that calls `ISelfUpdater.InstallAsync` from its own command is
covered too.

Apps need no changes, and the README's two-line setup keeps working.

### Added

- **`UpdateBanner.Suppress()`**, for apps with their own check or update UX: call it once
your command has shown the latest release, and the post-run banner stays silent for
the rest of the process. Additive public API.

### Changed

- **`PackageValidationBaselineVersion` moved to `1.1.0`**, the last shipped release.

---

## [1.1.0] — 2026-10-06

Moves to **Spectre.Console.Cli 0.57.2**. Spectre.Console.Cli now ships on its own release
Expand Down Expand Up @@ -304,7 +337,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.1.0...HEAD
[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v1.0.1
[0.3.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.3.1
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Stop hand-rolling the same "where's the latest release / what's my RID / where d
- **SHA-256 verification by default.** Reads from per-asset metadata when the source publishes it, or downloads a sibling `SHA256SUMS.txt`. Stack additional verifiers (minisign, cosign, Authenticode) on top via `IPackageVerifier`.
- **Atomic file swap, no restarter required.** Stages under `.update/`, swaps via `.old/`, cleans up `.old/` on the next launch — the running new binary is sufficient proof the swap succeeded.
- **Drop-in `update` command.** `CommandConfiguratorExtensions.AddUpdateCommand()` for a single command, `AddUpdateBranch()` for `update check` / `update apply`.
- **Background check + post-run banner.** `UpdateBanner.KickOffCheck()` and `RenderIfAvailable()` wire up the pl-app-style UX in two lines.
- **Background check + post-run banner.** `UpdateBanner.KickOffCheck()` and `RenderIfAvailable()` wire up the pl-app-style UX in two lines. The banner stays silent after `update` / `update check` has already shown the latest release; call `UpdateBanner.Suppress()` from your own update command to get the same.
- **Channels & pre-releases.** `Channel` option flows through every source.
- **Configurable cache, timeouts, opt-out env var, dev-build skip predicate.**
- **Zero compiler warnings, full XML documentation on the public surface**, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. SourceLink, deterministic builds, embedded symbols, snupkg symbol packages.
Expand Down Expand Up @@ -62,7 +62,7 @@ app.Configure(c =>
});
var exit = app.Run(args);

UpdateBanner.RenderIfAvailable(checkTask);
UpdateBanner.RenderIfAvailable(checkTask); // silent if `update` already handled it
return exit;
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.ComponentModel;
using System.Globalization;

using NextIteration.SpectreConsole.SelfUpdate.Internal;

using Spectre.Console;
using Spectre.Console.Cli;

Expand Down Expand Up @@ -78,6 +80,10 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se

_console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Latest release: [bold]{info.LatestTag}[/]");

// Every path from here has told the operator about the latest
// release, so the post-run banner would only repeat it.
UpdateSession.MarkHandled();

if (!info.IsUpdateAvailable)
{
_console.MarkupLine("[green]Already up to date.[/]");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.ComponentModel;
using System.Globalization;

using NextIteration.SpectreConsole.SelfUpdate.Internal;

using Spectre.Console;
using Spectre.Console.Cli;

Expand Down Expand Up @@ -111,6 +113,10 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se

_console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Latest release: [bold]{release.Tag}[/]");

// Every path from here has told the operator about the latest
// release, so the post-run banner would only repeat it.
UpdateSession.MarkHandled();

if (!settings.Force && !IsUpdateAvailable(current, release.Tag))
{
_console.MarkupLine("[green]Already up to date.[/]");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
namespace NextIteration.SpectreConsole.SelfUpdate.Internal
{
/// <summary>
/// Process-wide record of what the update UX has already done in this
/// run, read by <see cref="UpdateBanner.RenderIfAvailable"/> so the
/// post-run banner stays quiet after an update command has shown (or
/// installed) the latest release.
/// </summary>
/// <remarks>
/// Static rather than DI-scoped: the banner helper takes no service
/// provider, and the commands' public constructors cannot take an
/// internal state type. A plain <see cref="AsyncLocal{T}"/> would not
/// work either — a value set inside <c>app.Run</c>'s async flow does not
/// flow back out to <c>Main</c>.
/// </remarks>
internal static class UpdateSession
{
private sealed class State
{
public bool Handled;
public string? InstalledTag;
}

private static readonly State ProcessState = new();

// Test-only override. Tests run in parallel and share the process, so
// each test that reads or asserts this state opens its own scope; the
// scope's State object is inherited by every async flow the test
// starts, so commands and the banner inside the test see it.
private static readonly AsyncLocal<State?> TestScope = new();

private static State Current => TestScope.Value ?? ProcessState;

/// <summary>The update UX has shown the latest release in this process.</summary>
public static bool IsHandled => Volatile.Read(ref Current.Handled);

/// <summary>Tag of the release installed by this process, if any.</summary>
public static string? InstalledTag => Volatile.Read(ref Current.InstalledTag);

public static void MarkHandled() => Volatile.Write(ref Current.Handled, true);

public static void RecordInstalled(string tag)
{
ArgumentNullException.ThrowIfNull(tag);
Volatile.Write(ref Current.InstalledTag, tag);
}

/// <summary>
/// Isolate this state for the current async flow until the returned
/// scope is disposed. Tests only.
/// </summary>
internal static IDisposable BeginTestScope()
{
var previous = TestScope.Value;
TestScope.Value = new State();
return new Scope(previous);
}

private sealed class Scope(State? previous) : IDisposable
{
public void Dispose() => TestScope.Value = previous;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.SelfUpdate</PackageId>
<Version>1.1.0</Version>
<Version>1.2.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,14 +24,13 @@
<PackageIcon>icon.png</PackageIcon>
<!--
Validate the public surface against the last shipped release, not just
framework compatibility: an accidental API break relative to 1.0.1
framework compatibility: an accidental API break relative to 1.1.0
fails the build instead of shipping.

1.0.1 is the first stable release, so from here a break against this
baseline is a 2.0.0, not a judgement call. There is no 1.0.0 to
baseline against — it was never published (see CHANGELOG).
A break against this baseline is a 2.0.0, not a judgement call. The
baseline moves with each release, to the version before it.
-->
<PackageValidationBaselineVersion>1.0.1</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>1.1.0</PackageValidationBaselineVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using NextIteration.SpectreConsole.SelfUpdate.Internal;

namespace NextIteration.SpectreConsole.SelfUpdate.Pipeline
{
/// <summary>
Expand Down Expand Up @@ -125,6 +127,10 @@ public async Task InstallAsync(
progress?.Report(new UpdateProgressEvent(UpdateStage.Swapping, 0));
await SwapAsync(sourceDir, installDir, oldDir, _options.PreservePaths, onConflict, ct).ConfigureAwait(false);
progress?.Report(new UpdateProgressEvent(UpdateStage.Swapping, 1));

// This process is still the old binary, so its version no
// longer says what is installed. Tell the banner.
UpdateSession.RecordInstalled(release.Tag);
}
finally
{
Expand Down
37 changes: 37 additions & 0 deletions src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

using Microsoft.Extensions.DependencyInjection;

using NextIteration.SpectreConsole.SelfUpdate.Internal;

using Spectre.Console;

namespace NextIteration.SpectreConsole.SelfUpdate
Expand Down Expand Up @@ -36,11 +38,31 @@ public static class UpdateBanner
return updater.CheckAsync();
}

/// <summary>
/// Stop <see cref="RenderIfAvailable"/> rendering anything for the
/// rest of this process. Call it from your own command when it has
/// already shown the operator the latest release (or installed it),
/// so the post-run banner does not repeat it.
/// </summary>
/// <remarks>
/// The built-in <c>update</c> and <c>update check</c> commands
/// suppress the banner themselves, and a successful
/// <see cref="ISelfUpdater.InstallAsync(RemoteRelease, IProgress{UpdateProgressEvent}?, Func{UpdateConflict, CancellationToken, Task{UpdateConflictResolution}}?, CancellationToken)"/>
/// suppresses it for any release not newer than the one installed, so
/// apps using only those need not call this. The suppression is
/// process-wide and cannot be undone.
/// </remarks>
public static void Suppress() => UpdateSession.MarkHandled();

/// <summary>
/// Wait briefly for <paramref name="checkTask"/> to complete and
/// render a one-line banner if an update is available. No-op if
/// the task hasn't completed within <paramref name="waitFor"/>, if
/// it returned <see langword="null"/>, or if no update is available.
/// Also a no-op after <see cref="Suppress"/>, after the built-in
/// <c>update</c> or <c>update check</c> command has shown the latest
/// release, or when this process has already installed a release at
/// least as new as the one available.
/// </summary>
/// <param name="checkTask">The task returned by <see cref="KickOffCheck"/>.</param>
/// <param name="waitFor">Maximum time to wait. Defaults to 250 ms.</param>
Expand All @@ -52,6 +74,13 @@ public static void RenderIfAvailable(
{
ArgumentNullException.ThrowIfNull(checkTask);

// The update UX already told the operator about the latest
// release in this run; don't wait on the task just to repeat it.
if (UpdateSession.IsHandled)
{
return;
}

// Wait() on a faulted/cancelled task throws — keep the whole
// settle-then-read sequence inside the try/catch so background-
// check failures never derail the main app.
Expand All @@ -77,6 +106,14 @@ public static void RenderIfAvailable(
return;
}

// info.CurrentVersion is the running process's version, which is
// still the old binary after an install swapped the files.
if (UpdateSession.InstalledTag is { } installed
&& !Pipeline.UpdateChecker.IsNewer(installed, info.LatestTag))
{
return;
}

var ansi = console ?? AnsiConsole.Console;
if (info.ReleaseUrl is not null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;

using NextIteration.SpectreConsole.SelfUpdate.Commands;
using NextIteration.SpectreConsole.SelfUpdate.Internal;
using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure;

using Spectre.Console;
Expand Down Expand Up @@ -95,6 +96,22 @@ public async Task Execute_when_update_available_returns_two()
Assert.Contains("Release notes", console.Output, StringComparison.Ordinal);
}

[Fact]
public async Task Execute_when_update_available_suppresses_post_run_banner()
{
using var session = UpdateSession.BeginTestScope();
var (run, _, _) = BuildHarness(checker =>
{
checker.CurrentVersion = "1.0.0";
checker.CheckImpl = _ => Task.FromResult<UpdateInfo?>(
new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null));
});

await run();

Assert.Equal(string.Empty, RenderBannerAfterRun());
}

[Fact]
public async Task Execute_when_check_returns_null_returns_one()
{
Expand Down Expand Up @@ -138,6 +155,16 @@ public async Task Execute_when_no_release_url_omits_release_notes_line()

// ---------- helpers ----------

// What the README's post-run UpdateBanner.RenderIfAvailable would print
// after this command, given a background check that found v1.4.2.
private static string RenderBannerAfterRun()
{
var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null);
using var banner = new TestConsole();
UpdateBanner.RenderIfAvailable(Task.FromResult<UpdateInfo?>(info), waitFor: TimeSpan.FromSeconds(1), console: banner);
return banner.Output;
}

private (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action<StubUpdateChecker> configChecker)
{
var checker = new StubUpdateChecker();
Expand Down
Loading
Loading