(bugfix): refresh resource tables in place and stream logs follow

This commit is contained in:
Maksym Sadovnychyy 2026-08-21 19:46:53 +02:00
parent a77e12a648
commit 14952c50b9
17 changed files with 387 additions and 122 deletions

View File

@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.4.1] - 2026-08-21
### Fixed
- Staying on Pods (or any resource table) now picks up cluster changes on the automatic refresh. The grid previously only updated after leaving and returning to the view.
- Logs **Follow** tails new pod lines instead of hanging on the snapshot request, and turning Follow off no longer races the live stream into the log text.
## [0.4.0] - 2026-08-20
### Added

View File

@ -26,7 +26,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**.
### Release
1. Update [CHANGELOG.md](CHANGELOG.md) and bump `<Version>` in [src/Directory.Build.props](src/Directory.Build.props).
2. Commit on `main`, tag `v{version}`.
2. Commit on `main`, tag `v{version}` on HEAD (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
3. Run `utils\Invoke-ReleasePackage.bat`.
## Commit format

View File

@ -1,8 +1,8 @@
# MaksIT.ClusterConsole
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-52.7%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-43.9%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-60.2%25-green)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-53.6%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.1%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-61.3%25-green)
![.NET](https://img.shields.io/badge/.NET-10-512BD4)
![License](https://img.shields.io/badge/License-Apache%202.0-blue)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6)

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.0</Version>
<Version>0.4.1</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -1,8 +1,9 @@
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Net.Sockets;
using System.IO.Compression;
using System.Text.Json.Nodes;
using System.Runtime.CompilerServices;
using k8s;
using k8s.Models;
using MaksIT.Results;
@ -347,8 +348,8 @@ public sealed class ClusterSession : IClusterSession {
string podName,
string @namespace,
string? container,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) {
var stream = await _client.CoreV1.ReadNamespacedPodLogAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default) {
var response = await _client.CoreV1.ReadNamespacedPodLogWithHttpMessagesAsync(
podName,
@namespace,
container: container,
@ -356,9 +357,34 @@ public sealed class ClusterSession : IClusterSession {
tailLines: 200,
cancellationToken: cancellationToken).ConfigureAwait(false);
try {
var stream = response.Body;
if (stream is null)
yield break;
await foreach (var line in ReadLogLinesAsync(stream, cancellationToken).ConfigureAwait(false))
yield return line;
}
finally {
response.Dispose();
}
}
internal static async IAsyncEnumerable<string> ReadLogLinesAsync(
Stream stream,
[EnumeratorCancellation] CancellationToken cancellationToken = default) {
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream);
using var registration = cancellationToken.Register(stream.Dispose);
while (!cancellationToken.IsCancellationRequested) {
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
string? line;
try {
line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is ObjectDisposedException or IOException or OperationCanceledException) {
yield break;
}
if (line is null)
yield break;

View File

@ -0,0 +1,39 @@
using System.Text;
using MaksIT.ClusterConsole.Client;
namespace MaksIT.ClusterConsole.Tests;
public class PodLogsTests {
[Fact]
public async Task ReadLogLines_splits_on_newlines() {
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("a\nb\n"));
var lines = new List<string>();
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, TestContext.Current.CancellationToken))
lines.Add(line);
Assert.Equal(["a", "b"], lines);
}
[Fact]
public async Task ReadLogLines_keeps_a_final_line_without_newline() {
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("only"));
var lines = new List<string>();
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, TestContext.Current.CancellationToken))
lines.Add(line);
Assert.Equal(["only"], lines);
}
[Fact]
public async Task ReadLogLines_stops_when_cancelled() {
using var stream = new MemoryStream();
using var cts = new CancellationTokenSource();
cts.Cancel();
var lines = new List<string>();
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, cts.Token))
lines.Add(line);
Assert.Empty(lines);
}
}

View File

@ -2,6 +2,7 @@ using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
using MaksIT.ClusterConsole.Shared;
@ -14,6 +15,7 @@ internal sealed class LayoutPersistence {
private readonly Func<string?> _resourceTableId;
private readonly DispatcherTimer _saveTimer;
private readonly Dictionary<DataGrid, Func<string>> _tables = [];
private readonly Dictionary<DataGrid, PendingColumnSort> _pendingSorts = [];
private int _applyDepth;
private int _restoreSortPending;
private bool _attached;
@ -125,7 +127,10 @@ internal sealed class LayoutPersistence {
if (grid is null)
return;
_tables[grid] = key;
grid.LayoutUpdated += (_, _) => ScheduleSave();
grid.LayoutUpdated += (_, _) => {
TryApplyPendingSort(grid);
ScheduleSave();
};
grid.Sorting += (_, e) => PersistSort(grid, e.Column);
}
@ -215,30 +220,47 @@ internal sealed class LayoutPersistence {
private void ApplyColumnSort(DataGrid grid, string tableKey) {
var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey);
if (saved is null)
if (saved is null) {
_pendingSorts.Remove(grid);
return;
}
if (!Enum.TryParse<ListSortDirection>(saved.Direction, true, out var direction))
direction = ListSortDirection.Ascending;
DataGridColumn? column = null;
foreach (var candidate in grid.Columns) {
if (!string.Equals(ColumnKey(candidate), saved.Header, StringComparison.Ordinal))
continue;
column = candidate;
break;
}
_pendingSorts[grid] = new PendingColumnSort(saved.Header, direction);
Dispatcher.UIThread.Post(() => TryApplyPendingSort(grid), DispatcherPriority.Loaded);
}
if (column is null)
private void TryApplyPendingSort(DataGrid grid) {
if (!_pendingSorts.TryGetValue(grid, out var pending))
return;
var column = FindColumn(grid, pending.Header);
if (column is null) {
_pendingSorts.Remove(grid);
return;
}
// Sort() NREs when the column is detached or the header has not been generated yet.
if (!grid.IsAttachedToVisualTree() || !grid.IsEffectivelyVisible || column.ActualWidth <= 0)
return;
_pendingSorts.Remove(grid);
_restoreSortPending++;
column.Sort(pending.Direction);
Dispatcher.UIThread.Post(() => {
column.Sort(direction);
Dispatcher.UIThread.Post(() => {
if (_restoreSortPending > 0)
_restoreSortPending--;
}, DispatcherPriority.Background);
}, DispatcherPriority.Loaded);
if (_restoreSortPending > 0)
_restoreSortPending--;
}, DispatcherPriority.Background);
}
private static DataGridColumn? FindColumn(DataGrid grid, string header) {
foreach (var candidate in grid.Columns) {
if (string.Equals(ColumnKey(candidate), header, StringComparison.Ordinal))
return candidate;
}
return null;
}
private void PersistSort(DataGrid grid, DataGridColumn column) {
@ -302,6 +324,8 @@ internal sealed class LayoutPersistence {
_applyDepth--;
}
private sealed record PendingColumnSort(string Header, ListSortDirection Direction);
private sealed class ApplyScope(LayoutPersistence owner) : IDisposable {
public void Dispose() => owner.ReleaseApply();
}

View File

@ -670,7 +670,11 @@
Margin="12,0,0,0"
ToolTip.Tip="{Binding ActivePage.SelectedContainer.Display}" />
</DockPanel>
<TextBox Text="{Binding ActivePage.LogsText}" AcceptsReturn="True" IsReadOnly="True" FontFamily="Consolas, monospace" />
<TextBox Name="LogsTextBox"
Text="{Binding ActivePage.LogsText, Mode=OneWay}"
AcceptsReturn="True"
IsReadOnly="True"
FontFamily="Consolas, monospace" />
</DockPanel>
</TabItem>
<TabItem Header="Terminal" IsVisible="{Binding ActivePage.ShowTerminalTab}">

View File

@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
@ -18,6 +19,7 @@ namespace MaksIT.ClusterConsole.UI;
public partial class MainWindow : Window {
private LayoutPersistence? _layout;
private ClusterPageViewModel? _logsPage;
public MainWindow() {
InitializeComponent();
@ -37,7 +39,11 @@ public partial class MainWindow : Window {
viewModel.PropertyChanged += (_, e) => {
if (e.PropertyName is nameof(MainViewModel.SelectedNavItem) or nameof(MainViewModel.ActivePage))
RebuildColumns(viewModel);
if (e.PropertyName == nameof(MainViewModel.ActivePage))
HookLogsPage(viewModel.ActivePage);
};
HookLogsPage(viewModel.ActivePage);
viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel);
viewModel.VolumeFilesRequested += OpenVolumeFiles;
}
@ -47,6 +53,28 @@ public partial class MainWindow : Window {
window.Show(this);
}
private void HookLogsPage(ClusterPageViewModel? page) {
if (_logsPage is not null)
_logsPage.PropertyChanged -= OnLogsPagePropertyChanged;
_logsPage = page;
if (page is not null)
page.PropertyChanged += OnLogsPagePropertyChanged;
}
private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != nameof(ClusterPageViewModel.LogsText))
return;
if (sender is not ClusterPageViewModel { FollowLogs: true })
return;
var box = this.FindControl<TextBox>("LogsTextBox");
if (box is null)
return;
box.CaretIndex = box.Text?.Length ?? 0;
}
private async Task OpenConnectionsAsync(MainViewModel viewModel) {
var window = new ConnectionsWindow(viewModel.CreateConnectionsViewModel());
var connect = await window.ShowDialog<string?>(this);

View File

@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json.Nodes;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MaksIT.Results;
@ -17,7 +18,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private readonly ClusterChatService _chat;
private readonly Action<string> _setStatus;
private CancellationTokenSource? _logsCts;
private CancellationTokenSource? _refreshCts;
private DispatcherTimer? _refreshTimer;
private bool _refreshBusy;
private int _logsGeneration;
private bool _hasDaprCrd;
private bool _syncingNamespace;
private bool _syncingLayout;
@ -388,13 +391,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
public void PausePolling() =>
_refreshCts?.Cancel();
_refreshTimer?.Stop();
public void ResumePolling() =>
StartRefreshLoop();
public void ResumePolling() {
_refreshTimer ??= CreateRefreshTimer();
_refreshTimer.Start();
}
public void Dispose() {
PausePolling();
if (_refreshTimer is not null) {
_refreshTimer.Tick -= OnRefreshTimerTick;
_refreshTimer = null;
}
_logsCts?.Cancel();
_chatCts?.Cancel();
foreach (var row in LimitRows)
@ -454,6 +464,12 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (SelectedRow is null || !Rows.Contains(SelectedRow))
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
else if (!IsDirty) {
if (IsPodSelection)
ApplyContainers(SelectedRow.Document);
OverviewText = SelectedRow.FormatOverview(Containers);
}
var title = SelectedNavItem?.Title ?? "items";
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
@ -555,58 +571,64 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand]
private async Task RefreshRowsAsync() {
if (SelectedNavItem is null)
if (_refreshBusy || SelectedNavItem is null)
return;
await SampleClusterUsageAsync();
_refreshBusy = true;
try {
await SampleClusterUsageAsync();
if (SelectedNavItem.Id == ResourceCatalog.OverviewId) {
if (SelectedNavItem.Id == ResourceCatalog.OverviewId) {
_listedRows.Clear();
Rows.Clear();
await LoadOverviewIssuesAsync();
_setStatus($"Overview · {Name}");
return;
}
if (SelectedNavItem.Id == ResourceCatalog.WorkloadsOverviewId) {
await LoadWorkloadsOverviewAsync();
return;
}
if (SelectedNavItem.Id == ResourceCatalog.HelmChartsId) {
_listedRows.Clear();
Rows.Clear();
OverviewText = "Add chart repositories with the helm CLI. Releases are listed under Helm → Releases.";
_setStatus("Helm charts are managed via helm repos on this machine.");
return;
}
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
ShowPortForwardRows();
return;
}
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
var keepUid = SelectedRow?.Uid;
_listedRows.Clear();
Rows.Clear();
await LoadOverviewIssuesAsync();
_setStatus($"Overview · {Name}");
return;
if (!listed.IsSuccess) {
Rows.Clear();
_setStatus(string.Join("; ", listed.Messages));
return;
}
_listedRows.AddRange(listed.Value ?? []);
foreach (var column in SelectedDescriptor?.Columns ?? [])
FilterFor(column.Header).LoadValues(_listedRows);
SeedNamespaceColumnFromSelection();
ApplyColumnFilters(keepUid);
NotifyActionFlags();
NotifyDetailsUi();
OnPropertyChanged(nameof(IsDataEditor));
OnPropertyChanged(nameof(IsClusterDashboard));
OnPropertyChanged(nameof(IsWorkloadsDashboard));
OnPropertyChanged(nameof(IsResourceTable));
}
if (SelectedNavItem.Id == ResourceCatalog.WorkloadsOverviewId) {
await LoadWorkloadsOverviewAsync();
return;
finally {
_refreshBusy = false;
}
if (SelectedNavItem.Id == ResourceCatalog.HelmChartsId) {
_listedRows.Clear();
Rows.Clear();
OverviewText = "Add chart repositories with the helm CLI. Releases are listed under Helm → Releases.";
_setStatus("Helm charts are managed via helm repos on this machine.");
return;
}
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
ShowPortForwardRows();
return;
}
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
var keepUid = SelectedRow?.Uid;
_listedRows.Clear();
if (!listed.IsSuccess) {
Rows.Clear();
_setStatus(string.Join("; ", listed.Messages));
return;
}
_listedRows.AddRange(listed.Value ?? []);
foreach (var column in SelectedDescriptor?.Columns ?? [])
FilterFor(column.Header).LoadValues(_listedRows);
SeedNamespaceColumnFromSelection();
ApplyColumnFilters(keepUid);
NotifyActionFlags();
NotifyDetailsUi();
OnPropertyChanged(nameof(IsDataEditor));
OnPropertyChanged(nameof(IsClusterDashboard));
OnPropertyChanged(nameof(IsWorkloadsDashboard));
OnPropertyChanged(nameof(IsResourceTable));
}
[RelayCommand]
@ -1425,6 +1447,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private async Task LoadLogsAsync() {
_logsCts?.Cancel();
_logsCts = null;
var generation = ++_logsGeneration;
if (!ShowLogsTab) {
LogsText = "";
return;
@ -1446,32 +1469,58 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (FollowLogs) {
_logsCts = new CancellationTokenSource();
LogsText = "";
_ = FollowLogsLoopAsync(pod, ns, container, _logsCts.Token);
_ = FollowLogsLoopAsync(pod, ns, container, generation, _logsCts.Token);
return;
}
var logs = await _workspace.Session.GetLogsAsync(pod, ns, container, false, 200);
if (generation != _logsGeneration)
return;
LogsText = logs.IsSuccess ? logs.Value ?? "" : string.Join("; ", logs.Messages);
}
private async Task FollowLogsLoopAsync(string pod, string ns, string? container, CancellationToken cancellationToken) {
private async Task FollowLogsLoopAsync(
string pod,
string ns,
string? container,
int generation,
CancellationToken cancellationToken) {
if (_workspace.Session is null)
return;
try {
await foreach (var line in _workspace.Session.FollowLogsAsync(pod, ns, container, cancellationToken)) {
LogsText += line + Environment.NewLine;
if (LogsText.Length > 200_000)
LogsText = LogsText[^100_000..];
var text = line;
Dispatcher.UIThread.Post(() => {
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
return;
AppendLogLine(text);
});
}
}
catch (OperationCanceledException) {
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException) {
}
catch (Exception ex) {
LogsText = string.IsNullOrEmpty(LogsText) ? ex.Message : LogsText + Environment.NewLine + ex.Message;
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
return;
Dispatcher.UIThread.Post(() => {
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
return;
LogsText = string.IsNullOrEmpty(LogsText) ? ex.Message : LogsText + Environment.NewLine + ex.Message;
});
}
}
private void AppendLogLine(string line) {
LogsText += line + Environment.NewLine;
if (LogsText.Length > 200_000)
LogsText = LogsText[^100_000..];
}
private void ReplaceRelatedPods(IReadOnlyList<ResourceRow> pods) {
_updatingPodContext = true;
var keep = SelectedRelatedPod?.Name;
@ -1899,20 +1948,21 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
BrowseFilesCommand.NotifyCanExecuteChanged();
}
private void StartRefreshLoop() {
_refreshCts?.Cancel();
_refreshCts = new CancellationTokenSource();
var token = _refreshCts.Token;
_ = Task.Run(async () => {
while (!token.IsCancellationRequested) {
try {
await Task.Delay(TimeSpan.FromSeconds(5), token);
await RefreshRowsAsync();
}
catch (OperationCanceledException) {
break;
}
}
}, token);
private DispatcherTimer CreateRefreshTimer() {
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
timer.Tick += OnRefreshTimerTick;
return timer;
}
private void OnRefreshTimerTick(object? sender, EventArgs e) =>
_ = RefreshFromTimerAsync();
private async Task RefreshFromTimerAsync() {
try {
await RefreshRowsAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException) {
_setStatus(ex.Message);
}
}
}

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction.
.DESCRIPTION
Supports only the standard Keep a Changelog version line:
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
## [1.0.0] - 2026-05-24
## [0.1.0-alpha.1] - 2026-08-21
## [0.1.0-beta.1] - 2026-08-21
## [0.1.0-rc.1] - 2026-08-21
#>
function Get-ChangelogSemverPattern {
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
}
function Test-ReleaseSemver {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Version)) {
return $false
}
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
}
function Test-ReleaseSemverPrerelease {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
}
function Get-ReleaseSemverPrereleaseLabel {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
return $null
}
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
return $Matches[1].ToLowerInvariant()
}
return 'next'
}
function Get-ChangelogVersionHeaderPattern {
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-ChangelogNextVersionHeaderPattern {
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-LatestChangelogVersion {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim()
}
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection

View File

@ -8,10 +8,10 @@
.DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles .csproj, or from the nearest Directory.Build.props when the
csproj omits it. Writes version plus the resolved projectFiles (csproj paths
for later pack/publish) to the shared runtime context. Declares
providesVersion = $true so the engine can discover it as the single release
version source.
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
version plus the resolved projectFiles (csproj paths for later pack/publish)
to the shared runtime context. Declares providesVersion = $true so the engine
can discover it as the single release version source.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -135,6 +135,10 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'

View File

@ -28,6 +28,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings
$shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[string]$pluginSettings.access
}
$npmDistTag = $null
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
$npmDistTag = [string]$pluginSettings.npmDistTag
}
else {
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) {
foreach ($packageName in $publishOrder) {
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
}
return
}
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
$publishArgs += @('-w', $packageName)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
npm publish --access $access --userconfig $tempNpmRcPath
}
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
$publishArgs += @('--tag', $npmDistTag)
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
}
npm @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."

View File

@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
return $version
@ -69,6 +70,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$pluginSettings = $Settings
$shared = $Settings.context

View File

@ -7,9 +7,10 @@
.DESCRIPTION
Reads a single-line semver from the configured versionFilePath (default
repo-root VERSION). Useful for repositories without .csproj or package.json
version metadata. Declares providesVersion = $true so the engine can
discover it as the single release version source.
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
repositories without .csproj or package.json version metadata. Declares
providesVersion = $true so the engine can discover it as the single release
version source.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
}
$version = $version -replace '^[vV]', ''
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
return $version
@ -55,6 +57,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$shared = $Settings.context
$versionFileSetting = if ($Settings.versionFilePath) {

View File

@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
(see ChangelogSupport.psm1).
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -95,6 +96,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings
@ -128,6 +130,9 @@ function Invoke-Plugin {
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
}
return
}
@ -261,6 +266,10 @@ function Invoke-Plugin {
"--title", $releaseName,
"--notes-file", $notesFilePath
)
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {

View File

@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
tag version vs DotNetReleaseVersion, optional push tag to remote.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
tag version vs release version, optional push tag to remote.
The engine preflight no longer reads git tags; this plugin sets context.tag from the
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
@ -77,6 +78,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings
$shared = $Settings.context
@ -123,8 +125,9 @@ function Invoke-Plugin {
return
}
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
if ($tag -notmatch $tagPattern) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
return
}