(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] ## [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 ## [0.4.0] - 2026-08-20
### Added ### Added

View File

@ -26,7 +26,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**.
### Release ### Release
1. Update [CHANGELOG.md](CHANGELOG.md) and bump `<Version>` in [src/Directory.Build.props](src/Directory.Build.props). 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`. 3. Run `utils\Invoke-ReleasePackage.bat`.
## Commit format ## Commit format

View File

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

View File

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

View File

@ -1,8 +1,9 @@
using System.IO.Compression;
using System.Net; using System.Net;
using System.Net.Sockets;
using System.Text; using System.Text;
using System.Net.Sockets;
using System.IO.Compression;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Runtime.CompilerServices;
using k8s; using k8s;
using k8s.Models; using k8s.Models;
using MaksIT.Results; using MaksIT.Results;
@ -347,8 +348,8 @@ public sealed class ClusterSession : IClusterSession {
string podName, string podName,
string @namespace, string @namespace,
string? container, string? container,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { [EnumeratorCancellation] CancellationToken cancellationToken = default) {
var stream = await _client.CoreV1.ReadNamespacedPodLogAsync( var response = await _client.CoreV1.ReadNamespacedPodLogWithHttpMessagesAsync(
podName, podName,
@namespace, @namespace,
container: container, container: container,
@ -356,9 +357,34 @@ public sealed class ClusterSession : IClusterSession {
tailLines: 200, tailLines: 200,
cancellationToken: cancellationToken).ConfigureAwait(false); 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 reader = new StreamReader(stream);
using var registration = cancellationToken.Register(stream.Dispose);
while (!cancellationToken.IsCancellationRequested) { 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) if (line is null)
yield break; 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;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using MaksIT.ClusterConsole.Shared; using MaksIT.ClusterConsole.Shared;
@ -14,6 +15,7 @@ internal sealed class LayoutPersistence {
private readonly Func<string?> _resourceTableId; private readonly Func<string?> _resourceTableId;
private readonly DispatcherTimer _saveTimer; private readonly DispatcherTimer _saveTimer;
private readonly Dictionary<DataGrid, Func<string>> _tables = []; private readonly Dictionary<DataGrid, Func<string>> _tables = [];
private readonly Dictionary<DataGrid, PendingColumnSort> _pendingSorts = [];
private int _applyDepth; private int _applyDepth;
private int _restoreSortPending; private int _restoreSortPending;
private bool _attached; private bool _attached;
@ -125,7 +127,10 @@ internal sealed class LayoutPersistence {
if (grid is null) if (grid is null)
return; return;
_tables[grid] = key; _tables[grid] = key;
grid.LayoutUpdated += (_, _) => ScheduleSave(); grid.LayoutUpdated += (_, _) => {
TryApplyPendingSort(grid);
ScheduleSave();
};
grid.Sorting += (_, e) => PersistSort(grid, e.Column); grid.Sorting += (_, e) => PersistSort(grid, e.Column);
} }
@ -215,30 +220,47 @@ internal sealed class LayoutPersistence {
private void ApplyColumnSort(DataGrid grid, string tableKey) { private void ApplyColumnSort(DataGrid grid, string tableKey) {
var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey); var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey);
if (saved is null) if (saved is null) {
_pendingSorts.Remove(grid);
return; return;
}
if (!Enum.TryParse<ListSortDirection>(saved.Direction, true, out var direction)) if (!Enum.TryParse<ListSortDirection>(saved.Direction, true, out var direction))
direction = ListSortDirection.Ascending; direction = ListSortDirection.Ascending;
DataGridColumn? column = null; _pendingSorts[grid] = new PendingColumnSort(saved.Header, direction);
foreach (var candidate in grid.Columns) { Dispatcher.UIThread.Post(() => TryApplyPendingSort(grid), DispatcherPriority.Loaded);
if (!string.Equals(ColumnKey(candidate), saved.Header, StringComparison.Ordinal))
continue;
column = candidate;
break;
} }
if (column is null) private void TryApplyPendingSort(DataGrid grid) {
if (!_pendingSorts.TryGetValue(grid, out var pending))
return; 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++; _restoreSortPending++;
Dispatcher.UIThread.Post(() => { column.Sort(pending.Direction);
column.Sort(direction);
Dispatcher.UIThread.Post(() => { Dispatcher.UIThread.Post(() => {
if (_restoreSortPending > 0) if (_restoreSortPending > 0)
_restoreSortPending--; _restoreSortPending--;
}, DispatcherPriority.Background); }, DispatcherPriority.Background);
}, DispatcherPriority.Loaded); }
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) { private void PersistSort(DataGrid grid, DataGridColumn column) {
@ -302,6 +324,8 @@ internal sealed class LayoutPersistence {
_applyDepth--; _applyDepth--;
} }
private sealed record PendingColumnSort(string Header, ListSortDirection Direction);
private sealed class ApplyScope(LayoutPersistence owner) : IDisposable { private sealed class ApplyScope(LayoutPersistence owner) : IDisposable {
public void Dispose() => owner.ReleaseApply(); public void Dispose() => owner.ReleaseApply();
} }

View File

@ -670,7 +670,11 @@
Margin="12,0,0,0" Margin="12,0,0,0"
ToolTip.Tip="{Binding ActivePage.SelectedContainer.Display}" /> ToolTip.Tip="{Binding ActivePage.SelectedContainer.Display}" />
</DockPanel> </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> </DockPanel>
</TabItem> </TabItem>
<TabItem Header="Terminal" IsVisible="{Binding ActivePage.ShowTerminalTab}"> <TabItem Header="Terminal" IsVisible="{Binding ActivePage.ShowTerminalTab}">

View File

@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Globalization; using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
@ -18,6 +19,7 @@ namespace MaksIT.ClusterConsole.UI;
public partial class MainWindow : Window { public partial class MainWindow : Window {
private LayoutPersistence? _layout; private LayoutPersistence? _layout;
private ClusterPageViewModel? _logsPage;
public MainWindow() { public MainWindow() {
InitializeComponent(); InitializeComponent();
@ -37,7 +39,11 @@ public partial class MainWindow : Window {
viewModel.PropertyChanged += (_, e) => { viewModel.PropertyChanged += (_, e) => {
if (e.PropertyName is nameof(MainViewModel.SelectedNavItem) or nameof(MainViewModel.ActivePage)) if (e.PropertyName is nameof(MainViewModel.SelectedNavItem) or nameof(MainViewModel.ActivePage))
RebuildColumns(viewModel); RebuildColumns(viewModel);
if (e.PropertyName == nameof(MainViewModel.ActivePage))
HookLogsPage(viewModel.ActivePage);
}; };
HookLogsPage(viewModel.ActivePage);
viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel); viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel);
viewModel.VolumeFilesRequested += OpenVolumeFiles; viewModel.VolumeFilesRequested += OpenVolumeFiles;
} }
@ -47,6 +53,28 @@ public partial class MainWindow : Window {
window.Show(this); 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) { private async Task OpenConnectionsAsync(MainViewModel viewModel) {
var window = new ConnectionsWindow(viewModel.CreateConnectionsViewModel()); var window = new ConnectionsWindow(viewModel.CreateConnectionsViewModel());
var connect = await window.ShowDialog<string?>(this); var connect = await window.ShowDialog<string?>(this);

View File

@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using MaksIT.Results; using MaksIT.Results;
@ -17,7 +18,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private readonly ClusterChatService _chat; private readonly ClusterChatService _chat;
private readonly Action<string> _setStatus; private readonly Action<string> _setStatus;
private CancellationTokenSource? _logsCts; private CancellationTokenSource? _logsCts;
private CancellationTokenSource? _refreshCts; private DispatcherTimer? _refreshTimer;
private bool _refreshBusy;
private int _logsGeneration;
private bool _hasDaprCrd; private bool _hasDaprCrd;
private bool _syncingNamespace; private bool _syncingNamespace;
private bool _syncingLayout; private bool _syncingLayout;
@ -388,13 +391,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
} }
public void PausePolling() => public void PausePolling() =>
_refreshCts?.Cancel(); _refreshTimer?.Stop();
public void ResumePolling() => public void ResumePolling() {
StartRefreshLoop(); _refreshTimer ??= CreateRefreshTimer();
_refreshTimer.Start();
}
public void Dispose() { public void Dispose() {
PausePolling(); PausePolling();
if (_refreshTimer is not null) {
_refreshTimer.Tick -= OnRefreshTimerTick;
_refreshTimer = null;
}
_logsCts?.Cancel(); _logsCts?.Cancel();
_chatCts?.Cancel(); _chatCts?.Cancel();
foreach (var row in LimitRows) foreach (var row in LimitRows)
@ -454,6 +464,12 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (SelectedRow is null || !Rows.Contains(SelectedRow)) if (SelectedRow is null || !Rows.Contains(SelectedRow))
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid); 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 title = SelectedNavItem?.Title ?? "items";
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count; var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
@ -555,9 +571,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand] [RelayCommand]
private async Task RefreshRowsAsync() { private async Task RefreshRowsAsync() {
if (SelectedNavItem is null) if (_refreshBusy || SelectedNavItem is null)
return; return;
_refreshBusy = true;
try {
await SampleClusterUsageAsync(); await SampleClusterUsageAsync();
if (SelectedNavItem.Id == ResourceCatalog.OverviewId) { if (SelectedNavItem.Id == ResourceCatalog.OverviewId) {
@ -608,6 +626,10 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(IsWorkloadsDashboard)); OnPropertyChanged(nameof(IsWorkloadsDashboard));
OnPropertyChanged(nameof(IsResourceTable)); OnPropertyChanged(nameof(IsResourceTable));
} }
finally {
_refreshBusy = false;
}
}
[RelayCommand] [RelayCommand]
private async Task ApplyYamlAsync() { private async Task ApplyYamlAsync() {
@ -1425,6 +1447,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private async Task LoadLogsAsync() { private async Task LoadLogsAsync() {
_logsCts?.Cancel(); _logsCts?.Cancel();
_logsCts = null; _logsCts = null;
var generation = ++_logsGeneration;
if (!ShowLogsTab) { if (!ShowLogsTab) {
LogsText = ""; LogsText = "";
return; return;
@ -1446,31 +1469,57 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (FollowLogs) { if (FollowLogs) {
_logsCts = new CancellationTokenSource(); _logsCts = new CancellationTokenSource();
LogsText = ""; LogsText = "";
_ = FollowLogsLoopAsync(pod, ns, container, _logsCts.Token); _ = FollowLogsLoopAsync(pod, ns, container, generation, _logsCts.Token);
return; return;
} }
var logs = await _workspace.Session.GetLogsAsync(pod, ns, container, false, 200); var logs = await _workspace.Session.GetLogsAsync(pod, ns, container, false, 200);
if (generation != _logsGeneration)
return;
LogsText = logs.IsSuccess ? logs.Value ?? "" : string.Join("; ", logs.Messages); 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) if (_workspace.Session is null)
return; return;
try { try {
await foreach (var line in _workspace.Session.FollowLogsAsync(pod, ns, container, cancellationToken)) { await foreach (var line in _workspace.Session.FollowLogsAsync(pod, ns, container, cancellationToken)) {
var text = line;
Dispatcher.UIThread.Post(() => {
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
return;
AppendLogLine(text);
});
}
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException) {
}
catch (Exception ex) {
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; LogsText += line + Environment.NewLine;
if (LogsText.Length > 200_000) if (LogsText.Length > 200_000)
LogsText = LogsText[^100_000..]; LogsText = LogsText[^100_000..];
} }
}
catch (OperationCanceledException) {
}
catch (Exception ex) {
LogsText = string.IsNullOrEmpty(LogsText) ? ex.Message : LogsText + Environment.NewLine + ex.Message;
}
}
private void ReplaceRelatedPods(IReadOnlyList<ResourceRow> pods) { private void ReplaceRelatedPods(IReadOnlyList<ResourceRow> pods) {
_updatingPodContext = true; _updatingPodContext = true;
@ -1899,20 +1948,21 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
BrowseFilesCommand.NotifyCanExecuteChanged(); BrowseFilesCommand.NotifyCanExecuteChanged();
} }
private void StartRefreshLoop() { private DispatcherTimer CreateRefreshTimer() {
_refreshCts?.Cancel(); var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_refreshCts = new CancellationTokenSource(); timer.Tick += OnRefreshTimerTick;
var token = _refreshCts.Token; return timer;
_ = Task.Run(async () => { }
while (!token.IsCancellationRequested) {
private void OnRefreshTimerTick(object? sender, EventArgs e) =>
_ = RefreshFromTimerAsync();
private async Task RefreshFromTimerAsync() {
try { try {
await Task.Delay(TimeSpan.FromSeconds(5), token);
await RefreshRowsAsync(); await RefreshRowsAsync();
} }
catch (OperationCanceledException) { catch (Exception ex) when (ex is not OperationCanceledException) {
break; _setStatus(ex.Message);
} }
} }
}, token);
}
} }

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction. Keep a Changelog header parsing and section extraction.
.DESCRIPTION .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 ## [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 { 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 { 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 { function Get-LatestChangelogVersion {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim() 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 .DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles .csproj, or from the nearest Directory.Build.props when the projectFiles .csproj, or from the nearest Directory.Build.props when the
csproj omits it. Writes version plus the resolved projectFiles (csproj paths csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
for later pack/publish) to the shared runtime context. Declares version plus the resolved projectFiles (csproj paths for later pack/publish)
providesVersion = $true so the engine can discover it as the single release to the shared runtime context. Declares providesVersion = $true so the engine
version source. can discover it as the single release version source.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { 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)..." Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0] $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-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles' 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 "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings $pluginSettings = $Settings
$shared = $Settings.context $shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[string]$pluginSettings.access [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 = @() $publishOrder = @()
if ($pluginSettings.publishOrder) { if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) { if ($dryRun) {
foreach ($packageName in $publishOrder) { 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 return
} }
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) { foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) { if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath $publishArgs += @('-w', $packageName)
} }
else { else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName 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) { if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'." throw "Failed to publish npm package '$packageName'."

View File

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

View File

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

View File

@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md. shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD 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)) { 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 "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings $pluginSettings = $Settings
@ -128,6 +130,9 @@ function Invoke-Plugin {
} }
$releaseName = $releaseTitlePattern -replace '\{version\}', $version $releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" 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 return
} }
@ -261,6 +266,10 @@ function Invoke-Plugin {
"--title", $releaseName, "--title", $releaseName,
"--notes-file", $notesFilePath "--notes-file", $notesFilePath
) )
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs & gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

View File

@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead. branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD, Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
tag version vs DotNetReleaseVersion, optional push tag to remote. (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 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. 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 "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings $pluginSettings = $Settings
$shared = $Settings.context $shared = $Settings.context
@ -123,8 +125,9 @@ function Invoke-Plugin {
return return
} }
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { $tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z." 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 return
} }