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
38 changes: 21 additions & 17 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,36 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
8.0.x
10.0.x

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: csharp
# build-mode: none analyses the C# source directly, without a build.
# It is load-bearing, not a convenience, for two reasons:
#
# 1. paths-ignore (below) only takes effect in this mode. When CodeQL
# builds a compiled language, GitHub applies no path filter — every
# file the compiler sees is analysed, obj/ included — so under the
# explicit build this workflow used to run, paths-ignore was
# silently inert and the xUnit auto-generated entry point in obj/
# was analysed and flagged in every repo. Buildless extraction
# honours the filter, so the exclusion the standard mandates
# actually happens.
#
# 2. It reads the source across every target framework at once. These
# repos multi-target, and autobuild has picked a single TFM in the
# past, silently analysing half the code; the explicit build existed
# to guard against that. Buildless extraction reads the source
# itself, not one TFM's build output, so it covers all of it with no
# build step to get wrong.
build-mode: none
# security-and-quality is broader than the default security-extended;
# these are small libraries, so the extra findings are affordable.
queries: security-and-quality
# Analyse source only. obj/ and bin/ hold generated and compiled
# output — e.g. the xUnit auto-generated entry point — so findings
# there are noise against code no human maintains.
# there are noise against code no human maintains. Effective only
# under build-mode: none (above).
#
# query-filters excludes the two audit queries that fire on every
# P/Invoke declaration and call site (cs/unmanaged-code,
Expand All @@ -68,15 +81,6 @@ jobs:
- exclude:
id: cs/call-to-unmanaged-code

# Explicit build rather than autobuild: these repos multi-target, and
# autobuild has picked a single TFM in the past, silently analysing half
# the code. Restore is separate so a restore failure is legible.
- name: Restore
run: dotnet restore

- name: Build
run: dotnet build --configuration Release --no-restore

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
Expand Down
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.0.0] — 2026-08-21
## [1.0.0] — 2026-08-22

First stable release. The public surface is now covered by Semantic Versioning:
a breaking change to it requires a 2.0.0.
Expand Down Expand Up @@ -46,6 +46,14 @@ the override-carrying method, so none of them changed behaviour.

### Changed

- **Exception handling narrowed across the pipeline — unexpected exceptions now surface instead of being swallowed.** Nineteen `catch`-everything blocks caught any exception, so a genuine defect anywhere in the update path was silently reported as "no update available", "already up to date", or nothing at all, and could persist indefinitely. Each now catches only what its operation can actually produce: filesystem best-effort helpers take `IOException`/`UnauthorizedAccessException`, the cache file adds `JsonException`, archive extraction adds `InvalidDataException`/`NotSupportedException`, the HTTP sources take `HttpRequestException`/`JsonException`/`IOException`/`OperationCanceledException`, the `gh` source takes `GhProcessException` in place of the HTTP pair, and process termination takes `InvalidOperationException`/`Win32Exception`/`NotSupportedException`. The rollback paths in `UpdateInstaller` that catch broadly and *rethrow* are unchanged — they restore state rather than swallow. `UpdateCheckCommand` now matches `UpdateCommand` exactly (`when (ex is not OperationCanceledException)`): a command boundary still turns any failure into a message and an exit code, but lets Ctrl-C through rather than reporting it as a failed check. **This is a deliberate behaviour change**: a bug that used to be masked will now surface as an unhandled exception.

- **CodeQL analyses C# buildless** (NextIteration.Standards §4.4). GitHub applies `paths-ignore` to a compiled language *only* when it is analysed without a build, so the mandated `**/obj/**` exclusion was silently inert under the explicit build and the xUnit auto-generated entry point was analysed and flagged anyway. `build-mode: none` makes the exclusion take effect, and buildless extraction reads source across every target framework at once — which is what the explicit build existed to guarantee — so the Setup .NET, Restore and Build steps are gone.

- **Test consoles are owned by the fixture.** The three command harnesses hand their `TestConsole` to the caller, which reads `console.Output` after the harness returns, so it cannot be disposed at the creation site. Each test class now implements `IDisposable` and disposes every console it handed out at teardown, which disposes them properly rather than documenting why they were left undisposed.

- **Typed locals replace upcast arguments** in the `UpdateCleanup.Run` null-argument tests. `Run` is overloaded on `IServiceProvider` and `IUpdateInstaller`; a typed local pins which overload each test targets without an upcast expression at the call site.

- **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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
{
info = await _checker.CheckAsync(prereleaseOverride, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
// Mirrors UpdateCommand: a command boundary turns any failure into a
// message and an exit code, but must let cancellation through so
// Ctrl-C is not reported as "could not determine the latest release".
catch (Exception ex) when (ex is not OperationCanceledException)
{
_console.MarkupLineInterpolated(CultureInfo.InvariantCulture,
$"[red]Could not determine the latest release:[/] {ex.Message}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public static async Task ExtractAsync(string archivePath, string destinationDire
{
throw;
}
catch (Exception ex)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or InvalidDataException or NotSupportedException)
{
throw new UpdateException($"Failed to extract '{Path.GetFileName(archivePath)}': {ex.Message}", ex);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ internal static class UpdateCacheFile
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<UpdateCacheEntry>(json, JsonOpts);
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
// A missing, unreadable or corrupt cache is not an error — the
// caller falls back to querying the source.
return null;
}
}
Expand All @@ -43,7 +45,7 @@ public static void TryWrite(string path, UpdateCacheEntry entry)
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(entry, JsonOpts));
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
// Cache-write failures are non-fatal — the source will be
// hit again on the next invocation.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Reflection;
using System.Text;

Expand Down Expand Up @@ -98,8 +99,13 @@ internal UpdateChecker(
{
throw;
}
catch (Exception)
catch (Exception ex) when (ex is UpdateException or HttpRequestException
or IOException or JsonException or OperationCanceledException)
{
// A source that is unreachable, slow or serving junk means "no
// upgrade information; try again next tick". Anything else is a
// bug and propagates rather than being silently reported as
// "up to date".
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,10 @@ public void CleanupOldInstall()
private static void TrySwallow(Action action)
{
try { action(); }
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Non-fatal — will retry on next startup.
// Non-fatal — will retry on next startup. Only filesystem
// contention is swallowed; anything else is a bug and propagates.
}
}

Expand Down Expand Up @@ -398,7 +399,7 @@ internal static void RestoreFromOld(string oldDirectory, string installDirectory
Directory.Move(src, dest);
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best effort — partial-restore failures are surfaced
// implicitly through the pipeline exception.
Expand All @@ -419,7 +420,7 @@ private static void TryDeleteEntry(string path)
DeleteDirectoryRobustly(path);
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best effort.
}
Expand Down Expand Up @@ -488,7 +489,7 @@ private static void ClearReadOnlyAttributes(string path)
}
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best effort — if we can't clear an attribute (e.g. the file
// is locked), the subsequent Delete will surface the real
Expand Down Expand Up @@ -565,7 +566,7 @@ private static void ResetStaging(string stagingDir)
{
DeleteDirectoryRobustly(stagingDir);
}
catch (Exception ex)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw new UpdateException(
$"Unable to reset staging directory '{stagingDir}': {ex.Message}", ex);
Expand All @@ -579,7 +580,7 @@ private static void TryDeleteDirectory(string path)
{
DeleteDirectoryRobustly(path);
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best effort — staging is under .update/ which the next install or
// a subsequent CleanupOldInstall pass will eventually overwrite.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,13 @@ internal GhCliReleaseSource(
{
throw;
}
catch (Exception)
catch (Exception ex) when (ex is GhProcessException or JsonException
or IOException or OperationCanceledException)
{
// Source contract: swallow transient failures and return null.
// Narrowed to the transport/parse failures a source can actually
// hit — an unexpected exception is a bug and now propagates
// instead of being reported as "no update available".
return null;
}
}
Expand Down Expand Up @@ -197,7 +201,7 @@ private static void TryDelete(string path)
File.Delete(path);
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best effort.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;

namespace NextIteration.SpectreConsole.SelfUpdate.Sources
Expand Down Expand Up @@ -79,9 +80,10 @@ public static async Task<string> RunCaptureStdoutAsync(IReadOnlyList<string> arg
private static void TryKill(Process proc)
{
try { proc.Kill(entireProcessTree: true); }
catch
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException)
{
// Best effort — the process may already have exited.
// Best effort — the process may already have exited
// (InvalidOperationException) or refused the signal.
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,13 @@ internal HttpGitHubReleaseSource(
{
throw;
}
catch (Exception)
catch (Exception ex) when (ex is HttpRequestException or JsonException
or IOException or OperationCanceledException)
{
// Source contract: swallow transient failures and return null.
// Narrowed to the transport/parse failures a source can actually
// hit — an unexpected exception is a bug and now propagates
// instead of being reported as "no update available".
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,13 @@ private static bool IsHttps(Uri uri) =>
{
throw;
}
catch (Exception)
catch (Exception ex) when (ex is HttpRequestException or JsonException
or IOException or OperationCanceledException)
{
// Source contract: swallow transient failures and return null.
// Narrowed to the transport/parse failures a source can actually
// hit — an unexpected exception is a bug and now propagates
// instead of being reported as "no update available".
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ public static void RenderIfAvailable(
}
info = checkTask.GetAwaiter().GetResult();
}
catch
catch (Exception ex) when (ex is AggregateException or UpdateException
or HttpRequestException or IOException or OperationCanceledException)
{
// The banner is decoration: a check that failed or timed out
// simply renders nothing. Task.Wait surfaces a fault as an
// AggregateException, hence its presence here.
return;
}
if (info is null || !info.IsUpdateAvailable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,23 @@

namespace NextIteration.SpectreConsole.SelfUpdate.Tests
{
public sealed class CommandConfiguratorExtensionsTests
public sealed class CommandConfiguratorExtensionsTests : IDisposable
{
// The harness below hands its TestConsole to the caller, which reads
// console.Output after the harness method has returned — so the console
// cannot be disposed at its creation site. The fixture owns the lifetime
// instead and disposes every console it handed out when xUnit tears the
// class down.
private readonly List<TestConsole> _consoles = [];

public void Dispose()
{
foreach (var console in _consoles)
{
console.Dispose();
}
}

private static readonly string[] HelpArgs = ["--help"];
private static readonly string[] UpdateHelpArgs = ["update", "--help"];
private static readonly string[] OtaHelpArgs = ["ota", "--help"];
Expand Down Expand Up @@ -84,12 +99,10 @@ public void AddUpdateBranch_rejects_blank_name()

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

private static (CommandApp App, TestConsole Console) BuildApp(Action<IConfigurator> configure)
private (CommandApp App, TestConsole Console) BuildApp(Action<IConfigurator> configure)
{
// Deliberately not `using`: this console escapes via the return
// value and its lifetime belongs to the caller, which reads
// console.Output after the harness method has returned.
var console = new TestConsole();
_consoles.Add(console);
var registrar = new TestRegistrar(s =>
{
s.AddSingleton<IAnsiConsole>(console);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,23 @@

namespace NextIteration.SpectreConsole.SelfUpdate.Tests.Commands
{
public sealed class UpdateCheckCommandTests
public sealed class UpdateCheckCommandTests : IDisposable
{
// The harness below hands its TestConsole to the caller, which reads
// console.Output after the harness method has returned — so the console
// cannot be disposed at its creation site. The fixture owns the lifetime
// instead and disposes every console it handed out when xUnit tears the
// class down.
private readonly List<TestConsole> _consoles = [];

public void Dispose()
{
foreach (var console in _consoles)
{
console.Dispose();
}
}

private delegate Task<int> Runner(params string[] args);

[Fact]
Expand Down Expand Up @@ -123,14 +138,12 @@ public async Task Execute_when_no_release_url_omits_release_notes_line()

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

private static (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action<StubUpdateChecker> configChecker)
private (Runner Run, TestConsole Console, StubUpdateChecker Checker) BuildHarness(Action<StubUpdateChecker> configChecker)
{
var checker = new StubUpdateChecker();
configChecker(checker);
// Deliberately not `using`: this console escapes via the return
// value and its lifetime belongs to the caller, which reads
// console.Output after the harness method has returned.
var console = new TestConsole();
_consoles.Add(console);

var registrar = new TestRegistrar(s =>
{
Expand Down
Loading