mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(bugfix): refresh resource tables in place and stream logs follow
This commit is contained in:
parent
a77e12a648
commit
14952c50b9
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# MaksIT.ClusterConsole
|
# MaksIT.ClusterConsole
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
39
src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs
Normal file
39
src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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++;
|
||||||
|
column.Sort(pending.Direction);
|
||||||
Dispatcher.UIThread.Post(() => {
|
Dispatcher.UIThread.Post(() => {
|
||||||
column.Sort(direction);
|
if (_restoreSortPending > 0)
|
||||||
Dispatcher.UIThread.Post(() => {
|
_restoreSortPending--;
|
||||||
if (_restoreSortPending > 0)
|
}, DispatcherPriority.Background);
|
||||||
_restoreSortPending--;
|
}
|
||||||
}, 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,58 +571,64 @@ 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;
|
||||||
|
|
||||||
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();
|
_listedRows.Clear();
|
||||||
Rows.Clear();
|
if (!listed.IsSuccess) {
|
||||||
await LoadOverviewIssuesAsync();
|
Rows.Clear();
|
||||||
_setStatus($"Overview · {Name}");
|
_setStatus(string.Join("; ", listed.Messages));
|
||||||
return;
|
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));
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
if (SelectedNavItem.Id == ResourceCatalog.WorkloadsOverviewId) {
|
_refreshBusy = false;
|
||||||
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();
|
|
||||||
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]
|
[RelayCommand]
|
||||||
@ -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,32 +1469,58 @@ 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)) {
|
||||||
LogsText += line + Environment.NewLine;
|
var text = line;
|
||||||
if (LogsText.Length > 200_000)
|
Dispatcher.UIThread.Post(() => {
|
||||||
LogsText = LogsText[^100_000..];
|
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
AppendLogLine(text);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) {
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException) {
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
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) {
|
private void ReplaceRelatedPods(IReadOnlyList<ResourceRow> pods) {
|
||||||
_updatingPodContext = true;
|
_updatingPodContext = true;
|
||||||
var keep = SelectedRelatedPod?.Name;
|
var keep = SelectedRelatedPod?.Name;
|
||||||
@ -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) {
|
|
||||||
try {
|
private void OnRefreshTimerTick(object? sender, EventArgs e) =>
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), token);
|
_ = RefreshFromTimerAsync();
|
||||||
await RefreshRowsAsync();
|
|
||||||
}
|
private async Task RefreshFromTimerAsync() {
|
||||||
catch (OperationCanceledException) {
|
try {
|
||||||
break;
|
await RefreshRowsAsync();
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex) when (ex is not OperationCanceledException) {
|
||||||
}, token);
|
_setStatus(ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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'."
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user