diff --git a/CHANGELOG.md b/CHANGELOG.md index 58534b2..0d813f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5fd471..7ac4c6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**. ### Release 1. Update [CHANGELOG.md](CHANGELOG.md) and bump `` 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 diff --git a/README.md b/README.md index b12351e..4a66d78 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8f53ae0..fe2f03b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.4.0 + 0.4.1 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs index 7fd0c36..84b9a0c 100644 --- a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs +++ b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs @@ -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 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; diff --git a/src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs b/src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs new file mode 100644 index 0000000..d87a09b --- /dev/null +++ b/src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs @@ -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(); + 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(); + 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(); + await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, cts.Token)) + lines.Add(line); + + Assert.Empty(lines); + } +} diff --git a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs index ede6c91..80408e4 100644 --- a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs +++ b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs @@ -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 _resourceTableId; private readonly DispatcherTimer _saveTimer; private readonly Dictionary> _tables = []; + private readonly Dictionary _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(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(); } diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml index 251ab30..8e7d5cc 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml @@ -670,7 +670,11 @@ Margin="12,0,0,0" ToolTip.Tip="{Binding ActivePage.SelectedContainer.Display}" /> - + diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs index 60d349a..9a3db78 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs @@ -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("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(this); diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs index fc1de17..62faa47 100644 --- a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs +++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs @@ -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 _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 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); + } } } diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 index feb7afa..211c83a 100644 --- a/utils/modules/ChangelogSupport.psm1 +++ b/utils/modules/ChangelogSupport.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 index 0542965..98a857f 100644 --- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -8,10 +8,10 @@ .DESCRIPTION Dedicated version-loading plugin. Reads 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' diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index db6b0ec..7357ffe 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -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'." diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 index ad48e46..31f300a 100644 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -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 diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 index c7bb3bd..6095289 100644 --- a/utils/plugins/Platform/FileReleaseVersion.psm1 +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -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) { diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 61773e0..5528635 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -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) { diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index 43b6dfe..56153f6 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -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 }