(feature): crash dialog and flatpak release packaging

This commit is contained in:
Maksym Sadovnychyy 2026-09-30 20:36:00 +02:00
parent b01bf04e7f
commit ccc0740843
28 changed files with 1257 additions and 677 deletions

View File

@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.1.0-alpha.3] - 2026-09-30
Still alpha versus **0.1.0-alpha.2**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.
### Added
- Avalonia UI crash dialog for unhandled dispatcher, AppDomain, and task exceptions. The report is copyable and written as `crash-*.txt` under the product logs folder (`%AppData%/MaksIT/LTO Backup/logs`, and the Linux application-data equivalent).
- Flatpak desktop file and AppStream metainfo in `data/` (`com.maks_it.ltobackup`). README documents user and system `flatpak install`.
- Release publish is self-contained for `win-x64` and `linux-x64`. The portable zip stays win-x64. `WindowsInstaller` and `FlatpakPack` run in the release engine. GitHub release requires the zip, setup exe, and Flatpak; cleanup keeps those three.
### Changed
- Avalonia **12.1.2** (was 12.1.0).
- Linux UI stays on X11/XWayland (`UsePlatformDetect` only). Avalonia 12.1.2 native Wayland still hangs on GNOME `xdg_toplevel.configure(0, 0)` when launched from the GNOME app icon.
- Flatpak app id is lowercase `com.maks_it.ltobackup` (replaces `com.maks_it.LtoBackup`). Uninstall the old id before installing a new bundle. Finish args include `--device=all` (tape) and `--filesystem=home`. AppStream `<release version>` is stamped from the same `Directory.Build.props` version as the zip and setup exe, so `flatpak info` matches the bundle file name.
- Windows installer build also links `WixToolset.Util.wixext`.
- Vendored RepoUtils platform plugins: `CollectCoverage`, `ContainerEngineProbe`, `DiscoverPackageArtifacts`.
### Removed
- Unused npm pack plugins (`NpmBuild`, `NpmJestTest`, `NpmPack`, `NpmPackageSupport`, `NpmPublish`, `NpmReleaseVersion`). `NpmPublish` stays disabled in release settings.
## [0.1.0-alpha.2] - 2026-09-06 ## [0.1.0-alpha.2] - 2026-09-06
Still alpha versus **0.1.0-alpha.1**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line. Still alpha versus **0.1.0-alpha.1**. Emulator and build validated; physical Windows/Linux tape, changer, and Linux SMB staging are not hardware-proven yet. Not a production-ready line.

View File

@ -3,6 +3,6 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.1.0-alpha.2</Version> <Version>0.1.0-alpha.3</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -1,8 +1,8 @@
# MaksIT.LTO.Backup # MaksIT.LTO.Backup
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-39.3%25-yellow) ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-39.8%25-yellow)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.7%25-yellow) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-29.9%25-yellow)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-49.7%25-yellowgreen) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-49.5%25-yellowgreen)
![.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-GPLv2-blue) ![License](https://img.shields.io/badge/License-GPLv2-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)
@ -22,7 +22,7 @@ Device access modes:
- **Physical** — real drive/library on **Windows** (`\\.\Tape0`, `\\.\Changer0`; admin) or **Linux** (`/dev/nst0`, `/dev/sg*`; `tape`/`disk` group) - **Physical** — real drive/library on **Windows** (`\\.\Tape0`, `\\.\Changer0`; admin) or **Linux** (`/dev/nst0`, `/dev/sg*`; `tape`/`disk` group)
- **Emulated** — file-backed drive + library (no hardware; primary CI / no-hardware path) - **Emulated** — file-backed drive + library (no hardware; primary CI / no-hardware path)
> Current line is **`0.1.0-alpha.2`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk. > Current line is **`0.1.0-alpha.3`** (prerelease, not production-ready). Emulator coverage is the primary validation path; physical tape/changer and Linux SMB are not hardware-proven yet. Use at your own risk.
--- ---
@ -237,6 +237,32 @@ Vendored under `utils/` (from MaksIT RepoUtils community):
GitHub assets are siblings: portable `maksit-lto-backup-{version}.zip` (win-x64), Windows setup exe (Avalonia UI), and Flatpak (Avalonia UI). The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian. GitHub assets are siblings: portable `maksit-lto-backup-{version}.zip` (win-x64), Windows setup exe (Avalonia UI), and Flatpak (Avalonia UI). The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian.
### Linux (Flatpak)
GitHub releases include `maksit-lto-backup-{version}.flatpak` (Avalonia UI).
**User** (this account only):
```bash
flatpak install --user ./maksit-lto-backup-{version}.flatpak
flatpak run com.maks_it.ltobackup
```
**System** (all users):
```bash
sudo flatpak install --system ./maksit-lto-backup-{version}.flatpak
flatpak run com.maks_it.ltobackup
```
Uninstall: `flatpak uninstall --user com.maks_it.ltobackup` or `sudo flatpak uninstall --system com.maks_it.ltobackup`.
The previous id `com.maks_it.LtoBackup` is replaced by this lowercase id. Uninstall the old app before installing the new bundle if it was installed.
If GNOME or KDE does not show a launcher icon, `flatpak run` may warn that `/var/lib/flatpak/exports/share` and `~/.local/share/flatpak/exports/share` are not on `XDG_DATA_DIRS`. Log out and back in once so the session picks up those paths.
Linux uses X11/XWayland (Avalonia native Wayland still hangs on GNOME). The sandbox grants `--device=all` for tape and `--filesystem=home` for backup paths. AppStream and the desktop file live in [`data/`](data/).
Settings: Settings:
- `utils/engines/test/scriptSettings.json` - `utils/engines/test/scriptSettings.json`

View File

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=LTO Backup
GenericName=Tape backup
Comment=LTO tape backup and restore
Exec=maksit-lto-backup
Icon=com.maks_it.ltobackup
StartupWMClass=com.maks_it.ltobackup
StartupNotify=true
Terminal=false
Categories=Utility;Archiving;
Keywords=lto;tape;backup;restore;

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.maks_it.ltobackup</id>
<name>LTO Backup</name>
<summary>LTO tape backup and restore</summary>
<developer id="com.maks_it">
<name>MaksIT</name>
</developer>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-2.0</project_license>
<description>
<p>Run LTO tape backup and restore from the desktop. Inventory a drive or library, start jobs, and watch progress without a vendor GUI.</p>
<p>The Flatpak is the Avalonia UI. Tape devices need host SCSI/sg access; backup destinations use the home folder.</p>
<ul>
<li>Drive and library inventory</li>
<li>Backup and restore jobs</li>
<li>Settings and a worker/service monitor</li>
</ul>
</description>
<launchable type="desktop-id">com.maks_it.ltobackup.desktop</launchable>
<provides>
<id>com.maks_it.LtoBackup</id>
</provides>
<replaces>
<id>com.maks_it.LtoBackup</id>
</replaces>
<url type="homepage">https://maks-it.com/</url>
<url type="bugtracker">https://github.com/MAKS-IT-COM/maksit-lto-backup/issues</url>
<url type="vcs-browser">https://github.com/MAKS-IT-COM/maksit-lto-backup</url>
<url type="donation">https://www.buymeacoffee.com/maksitcom</url>
<branding>
<color type="primary" scheme_preference="light">#33A5CF</color>
<color type="primary" scheme_preference="dark">#003D7A</color>
</branding>
<content_rating type="oars-1.1"/>
<requires>
<control>keyboard</control>
<control>pointing</control>
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.1.0-alpha.3" date="2026-09-30">
<url type="details">https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.3/CHANGELOG.md</url>
<description>
<p>Crash dialog with a copyable report and crash log. Linux Flatpak uses X11/XWayland, lowercase app id com.maks_it.ltobackup, and the same release version as the zip and setup exe.</p>
</description>
</release>
<release version="0.1.0-alpha.2" date="2026-09-06">
<url type="details">https://github.com/MAKS-IT-COM/maksit-lto-backup/blob/v0.1.0-alpha.2/CHANGELOG.md</url>
<description>
<p>Linux Flatpak uses X11/XWayland from the GNOME app icon and a lowercase app id. GitHub ships a sibling .flatpak of the Avalonia UI.</p>
</description>
</release>
</releases>
</component>

View File

@ -0,0 +1,77 @@
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
namespace MaksIT.LTO.Backup.Shared;
/// <summary>Formats unhandled exceptions for a copyable dialog and a crash log file.</summary>
public static class ErrorReport {
public static string Capture(Exception exception) {
ArgumentNullException.ThrowIfNull(exception);
var body = Format(exception);
var path = TryWrite(body);
if (string.IsNullOrWhiteSpace(path))
return body;
return body + Environment.NewLine + Environment.NewLine + "Log: " + path;
}
public static string Format(Exception exception) {
ArgumentNullException.ThrowIfNull(exception);
var text = new StringBuilder();
text.AppendLine("LTO Backup " + Version());
text.AppendLine("MaksIT");
text.AppendLine(DateTimeOffset.UtcNow.ToString("u"));
text.AppendLine(RuntimeInformation.OSDescription);
text.AppendLine(RuntimeInformation.FrameworkDescription);
text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process");
text.AppendLine();
AppendException(text, exception);
return text.ToString().TrimEnd();
}
public static string? TryWrite(string report) {
try {
var dir = UserSettingsPath.LogsDirectory(ConfigurationFileService.ProductFolder);
Directory.CreateDirectory(dir);
var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt";
var path = Path.Combine(dir, name);
File.WriteAllText(path, report ?? "");
return path;
}
catch {
return null;
}
}
private static string Version() {
var version = Assembly.GetEntryAssembly()?.GetName().Version;
return version is null ? "" : version.ToString();
}
private static void AppendException(StringBuilder text, Exception exception) {
var seen = new HashSet<Exception>();
var current = exception;
var depth = 0;
while (current is not null && seen.Add(current)) {
if (depth > 0)
text.AppendLine().AppendLine("--- inner ---");
text.AppendLine(current.GetType().FullName);
text.AppendLine(current.Message);
if (!string.IsNullOrWhiteSpace(current.StackTrace))
text.AppendLine(current.StackTrace);
if (current is AggregateException aggregate) {
foreach (var inner in aggregate.InnerExceptions) {
if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner))
continue;
text.AppendLine().AppendLine("--- aggregate ---");
text.AppendLine(inner.ToString());
}
}
current = current.InnerException;
depth++;
}
}
}

View File

@ -9,9 +9,14 @@ namespace MaksIT.LTO.Backup.Shared;
/// </summary> /// </summary>
public static class UserSettingsPath { public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") => public static string Get(string product, string fileName = "settings.json") =>
Path.Combine(ProductDirectory(product), fileName);
public static string LogsDirectory(string product) =>
Path.Combine(ProductDirectory(product), "logs");
public static string ProductDirectory(string product) =>
Path.Combine( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MaksIT", "MaksIT",
product, product);
fileName);
} }

View File

@ -0,0 +1,146 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using MaksIT.LTO.Backup.Shared;
using MaksIT.LTO.Backup.UI.ViewModels;
using MaksIT.LTO.Backup.UI.Windows;
namespace MaksIT.LTO.Backup.UI.Dialogs;
internal static class ErrorDialog {
private static int _open;
public static void Report(Exception? exception) =>
Present(exception, wait: false);
public static void ReportBlocking(Exception? exception) =>
Present(exception, wait: true);
private static void Present(Exception? exception, bool wait) {
if (exception is null)
return;
var report = ErrorReport.Capture(exception);
var dispatcher = TryDispatcher();
if (dispatcher is null) {
if (wait)
ShowStandalone(report);
return;
}
if (dispatcher.CheckAccess()) {
if (wait)
ShowUntilClosed(report);
else
_ = ShowAsync(report);
return;
}
if (wait)
dispatcher.Invoke(() => ShowUntilClosed(report));
else
dispatcher.Post(() => _ = ShowAsync(report));
}
private static Dispatcher? TryDispatcher() {
try {
return Dispatcher.UIThread;
}
catch {
return null;
}
}
private static async Task ShowAsync(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
var window = Create(report);
var owner = ActiveWindow();
if (owner is { IsVisible: true }) {
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
await window.ShowDialog(owner);
return;
}
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
var closed = new TaskCompletionSource();
window.Closed += (_, _) => closed.TrySetResult();
window.Show();
await closed.Task;
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static void ShowUntilClosed(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
var window = Create(report);
var owner = ActiveWindow();
var closed = false;
window.Closed += (_, _) => closed = true;
if (owner is { IsVisible: true }) {
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
window.Show(owner);
}
else {
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
window.Show();
}
var dispatcher = Dispatcher.UIThread;
while (!closed)
dispatcher.RunJobs();
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static void ShowStandalone(string report) {
if (Interlocked.Exchange(ref _open, 1) != 0)
return;
try {
AppBuilder.Configure<Application>()
.UsePlatformDetect()
.AfterSetup(builder => {
if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
return;
life.ShutdownMode = ShutdownMode.OnMainWindowClose;
life.MainWindow = Create(report);
})
.StartWithClassicDesktopLifetime([]);
}
catch {
}
finally {
Interlocked.Exchange(ref _open, 0);
}
}
private static ErrorWindow Create(string report) =>
new() {
DataContext = new ErrorReportViewModel(report),
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
private static Window? ActiveWindow() {
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life)
return null;
foreach (var window in life.Windows) {
if (window.IsActive && window is not ErrorWindow)
return window;
}
return life.MainWindow is ErrorWindow ? null : life.MainWindow;
}
}

View File

@ -12,10 +12,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" /> <PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" /> <PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.0" /> <PackageReference Include="Avalonia.Themes.Simple" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" /> <PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />

View File

@ -1,16 +1,48 @@
using Avalonia; using Avalonia;
using Avalonia.Logging; using Avalonia.Logging;
using Avalonia.Threading;
using MaksIT.LTO.Backup.UI.Dialogs;
namespace MaksIT.LTO.Backup.UI; namespace MaksIT.LTO.Backup.UI;
internal static class Program { internal static class Program {
[STAThread] [STAThread]
public static void Main(string[] args) => public static void Main(string[] args) {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled;
TaskScheduler.UnobservedTaskException += OnUnobservedTask;
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex) {
ErrorDialog.ReportBlocking(ex);
}
}
// Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's
// xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon).
public static AppBuilder BuildAvaloniaApp() => public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>() AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.LogToTrace(LogEventLevel.Warning); .LogToTrace(LogEventLevel.Warning)
.AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled);
private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) {
e.Handled = true;
ErrorDialog.Report(e.Exception);
}
private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) {
if (e.ExceptionObject is not Exception ex)
return;
if (e.IsTerminating)
ErrorDialog.ReportBlocking(ex);
else
ErrorDialog.Report(ex);
}
private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) {
e.SetObserved();
ErrorDialog.Report(e.Exception);
}
} }

View File

@ -11,6 +11,12 @@
<Setter Property="Foreground" Value="#9aa0a6" /> <Setter Property="Foreground" Value="#9aa0a6" />
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />
</Style> </Style>
<Style Selector="TextBox.error-dump">
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Ubuntu Mono, monospace" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="#0E1620" />
<Setter Property="Foreground" Value="#E8F4FC" />
</Style>
<Style Selector="TextBlock.warning"> <Style Selector="TextBlock.warning">
<Setter Property="Foreground" Value="#ff4d6d" /> <Setter Property="Foreground" Value="#ff4d6d" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />

View File

@ -0,0 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public sealed partial class ErrorReportViewModel : ObservableObject {
public ErrorReportViewModel(string report) {
Report = report ?? "";
}
public string Report { get; }
[ObservableProperty]
private string copyStatus = "";
public void MarkCopied() =>
CopyStatus = "Copied";
}

View File

@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.LTO.Backup.UI.ViewModels"
x:Class="MaksIT.LTO.Backup.UI.Windows.ErrorWindow"
x:DataType="vm:ErrorReportViewModel"
Width="720"
Height="520"
MinWidth="480"
MinHeight="360"
CanResize="True"
ShowInTaskbar="True"
Topmost="True"
WindowStartupLocation="CenterOwner"
Icon="/Assets/icon.ico"
Title="LTO Backup">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<TextBlock Text="{Binding CopyStatus}"
Classes="hint"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<Button Content="Copy"
Click="OnCopyClick" />
<Button Content="Close"
Padding="16,8"
Click="OnCloseClick"
IsDefault="True"
IsCancel="True" />
</StackPanel>
<TextBlock DockPanel.Dock="Top"
Text="Something went wrong. The details below are also saved to a crash log."
Classes="hint"
TextWrapping="Wrap"
Margin="0,0,0,12" />
<TextBox Text="{Binding Report}"
Classes="error-dump"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Window>

View File

@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using MaksIT.LTO.Backup.UI.ViewModels;
namespace MaksIT.LTO.Backup.UI.Windows;
public partial class ErrorWindow : Window {
public ErrorWindow() {
InitializeComponent();
}
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
if (DataContext is not ErrorReportViewModel vm)
return;
var clipboard = Clipboard;
if (clipboard is null)
return;
try {
await clipboard.SetTextAsync(vm.Report);
vm.MarkCopied();
}
catch {
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@ -0,0 +1,40 @@
using MaksIT.LTO.Backup.Shared;
namespace MaksIT.LTO.Tests;
public class ErrorReportTests {
[Fact]
public void Format_IncludesTypeMessageInnerAndStack() {
Exception thrown;
try {
throw new InvalidOperationException("outer", new ArgumentException("inner"));
}
catch (Exception ex) {
thrown = ex;
}
var text = ErrorReport.Format(thrown);
Assert.Contains("LTO Backup", text, StringComparison.Ordinal);
Assert.Contains("MaksIT", text, StringComparison.Ordinal);
Assert.Contains("InvalidOperationException", text, StringComparison.Ordinal);
Assert.Contains("outer", text, StringComparison.Ordinal);
Assert.Contains("--- inner ---", text, StringComparison.Ordinal);
Assert.Contains("ArgumentException", text, StringComparison.Ordinal);
Assert.Contains("inner", text, StringComparison.Ordinal);
Assert.Contains(nameof(Format_IncludesTypeMessageInnerAndStack), text, StringComparison.Ordinal);
}
[Fact]
public void Format_IncludesAggregateInners() {
var error = new AggregateException(
"batch",
new InvalidOperationException("one"),
new ArgumentException("two"));
var text = ErrorReport.Format(error);
Assert.Contains("AggregateException", text, StringComparison.Ordinal);
Assert.Contains("one", text, StringComparison.Ordinal);
Assert.Contains("two", text, StringComparison.Ordinal);
Assert.Contains("--- aggregate ---", text, StringComparison.Ordinal);
}
}

View File

@ -43,13 +43,66 @@
"..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\MaksIT.LTO.Backup.UI.csproj", "..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\MaksIT.LTO.Backup.UI.csproj",
"..\\..\\..\\src\\MaksIT.LTO.Backup.Service\\MaksIT.LTO.Backup.Service.csproj" "..\\..\\..\\src\\MaksIT.LTO.Backup.Service\\MaksIT.LTO.Backup.Service.csproj"
], ],
"artifactsDir": "..\\..\\..\\releases" "artifactsDir": "..\\..\\..\\releases",
"runtimeIdentifiers": [
"win-x64",
"linux-x64"
],
"selfContained": true,
"publishSingleFile": false
}, },
{ {
"name": "DotNetCreateArchive", "name": "DotNetCreateArchive",
"stageLabel": "build", "stageLabel": "build",
"enabled": true, "enabled": true,
"zipNamePattern": "maksit-lto-backup-{version}.zip" "zipNamePattern": "maksit-lto-backup-{version}.zip",
"portableRuntimeIdentifier": "win-x64"
},
{
"name": "WindowsInstaller",
"stageLabel": "build",
"enabled": true,
"appName": "MaksIT LTO Backup",
"manufacturer": "MaksIT",
"installFolderName": "LTO Backup",
"upgradeCode": "b3f7c2a1-8e4d-4f90-9c1b-6a2e5d8f3b10",
"executableName": "MaksIT.LTO.Backup.UI.exe",
"runtimeIdentifier": "win-x64",
"publishDir": "..\\..\\..\\releases\\MaksIT.LTO.Backup.UI\\win-x64",
"installScope": "perMachine",
"iconPath": "..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\Assets\\icon.ico",
"exeNamePattern": "maksit-lto-backup-{version}.exe"
},
{
"name": "FlatpakPack",
"stageLabel": "build",
"enabled": true,
"appId": "com.maks_it.ltobackup",
"appName": "MaksIT LTO Backup",
"command": "maksit-lto-backup",
"executableName": "MaksIT.LTO.Backup.UI",
"runtimeIdentifier": "linux-x64",
"publishDir": "..\\..\\..\\releases\\MaksIT.LTO.Backup.UI\\linux-x64",
"summary": "LTO tape backup and restore",
"description": "Desktop UI for LTO tape backup and restore. Inventory, backup, and restore jobs for a tape drive or library.",
"projectLicense": "GPL-2.0",
"categories": "Utility;Archiving;",
"iconPath": "..\\..\\..\\src\\MaksIT.LTO.Backup.UI\\Assets\\icon.png",
"metainfoPath": "..\\..\\..\\data\\com.maks_it.ltobackup.metainfo.xml",
"desktopPath": "..\\..\\..\\data\\com.maks_it.ltobackup.desktop",
"finishArgs": [
"--share=ipc",
"--share=network",
"--socket=wayland",
"--socket=x11",
"--device=all",
"--filesystem=home"
],
"bundleNamePattern": "maksit-lto-backup-{version}.flatpak",
"buildBundle": true,
"attachSourceZip": false,
"wslDistro": "Debian",
"whenBuilderMissing": "skip"
}, },
{ {
"name": "ReleasePublishGuard", "name": "ReleasePublishGuard",
@ -72,7 +125,8 @@
"githubSecret": "GitClone", "githubSecret": "GitClone",
"repository": "https://github.com/MAKS-IT-COM/maksit-lto-backup", "repository": "https://github.com/MAKS-IT-COM/maksit-lto-backup",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}" "releaseTitlePattern": "Release {version}",
"requireReleaseAssets": true
}, },
{ {
"name": "DotNetNuGet", "name": "DotNetNuGet",
@ -98,7 +152,9 @@
"*" "*"
], ],
"excludePatterns": [ "excludePatterns": [
"*.zip" "*.zip",
"*.exe",
"*.flatpak"
] ]
} }
] ]

View File

@ -323,8 +323,33 @@ function New-WixPackageXml {
$desktopProp = $xml.CreateElement('Property', $ns) $desktopProp = $xml.CreateElement('Property', $ns)
$null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT') $null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $desktopProp.SetAttribute('Value', '0') $null = $desktopProp.SetAttribute('Value', '0')
$null = $desktopProp.SetAttribute('Secure', 'yes')
$null = $package.AppendChild($desktopProp) $null = $package.AppendChild($desktopProp)
# Present when a previous install wrote the desktop shortcut marker.
# Burn also searches HKCU as the user and passes DESKTOPSHORTCUTFOUND,
# because a per-machine execute sequence runs as SYSTEM.
$foundProp = $xml.CreateElement('Property', $ns)
$null = $foundProp.SetAttribute('Id', 'DESKTOPSHORTCUTFOUND')
$null = $foundProp.SetAttribute('Secure', 'yes')
$foundSearch = $xml.CreateElement('RegistrySearch', $ns)
$null = $foundSearch.SetAttribute('Id', 'DesktopShortcutFoundSearch')
$null = $foundSearch.SetAttribute('Root', 'HKCU')
$null = $foundSearch.SetAttribute('Key', "Software\$Manufacturer\$AppName")
$null = $foundSearch.SetAttribute('Name', 'desktop')
$null = $foundSearch.SetAttribute('Type', 'raw')
$null = $foundProp.AppendChild($foundSearch)
$null = $package.AppendChild($foundProp)
# Checkbox off on reinstall must not drop an icon that is already there.
# Major upgrade removes the previous shortcut; this installs that one again.
$keepShortcut = $xml.CreateElement('SetProperty', $ns)
$null = $keepShortcut.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $keepShortcut.SetAttribute('Value', '1')
$null = $keepShortcut.SetAttribute('Before', 'CostFinalize')
$null = $keepShortcut.SetAttribute('Condition', 'DESKTOPSHORTCUTFOUND = "1"')
$null = $package.AppendChild($keepShortcut)
$productFolder = Get-DesktopInstallFolderName ` $productFolder = Get-DesktopInstallFolderName `
-AppName $AppName ` -AppName $AppName `
-Manufacturer $Manufacturer ` -Manufacturer $Manufacturer `
@ -449,7 +474,8 @@ function New-WixPackageXml {
$desktop = $xml.CreateElement('Component', $ns) $desktop = $xml.CreateElement('Component', $ns)
$null = $desktop.SetAttribute('Id', 'DesktopShortcut') $null = $desktop.SetAttribute('Id', 'DesktopShortcut')
$null = $desktop.SetAttribute('Directory', 'DesktopFolder') $null = $desktop.SetAttribute('Directory', 'DesktopFolder')
$null = $desktop.SetAttribute('Guid', '*') $shortcutGuid = Get-DesktopShortcutComponentGuid -Manufacturer $Manufacturer -AppName $AppName
$null = $desktop.SetAttribute('Guid', $shortcutGuid.ToString('D'))
$null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1') $null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1')
$desktopShortcut = $xml.CreateElement('Shortcut', $ns) $desktopShortcut = $xml.CreateElement('Shortcut', $ns)
$null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut') $null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut')
@ -461,6 +487,26 @@ function New-WixPackageXml {
} }
$null = $desktop.AppendChild($desktopShortcut) $null = $desktop.AppendChild($desktopShortcut)
# Same file name is replaced. A second ".lnk" (and the "(2)" copy) is removed
# before CreateShortcuts so a checked box does not leave two desktop icons.
$cleanupNames = [System.Collections.Generic.List[string]]::new()
$cleanupNames.Add($productFolder)
if (-not $cleanupNames.Contains($AppName)) {
$cleanupNames.Add($AppName)
}
$cleanupIndex = 0
foreach ($shortcutName in $cleanupNames) {
foreach ($suffix in @('', ' (2)')) {
$cleanupIndex++
$removeLnk = $xml.CreateElement('RemoveFile', $ns)
$null = $removeLnk.SetAttribute('Id', "DesktopShortcutCleanup$cleanupIndex")
$null = $removeLnk.SetAttribute('Name', "$shortcutName$suffix.lnk")
$null = $removeLnk.SetAttribute('On', 'install')
$null = $desktop.AppendChild($removeLnk)
}
}
$desktopReg = $xml.CreateElement('RegistryValue', $ns) $desktopReg = $xml.CreateElement('RegistryValue', $ns)
$null = $desktopReg.SetAttribute('Root', 'HKCU') $null = $desktopReg.SetAttribute('Root', 'HKCU')
$null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName") $null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
@ -496,9 +542,10 @@ function Get-DefaultFlatpakFinishArgs {
return @( return @(
'--share=ipc', '--share=ipc',
'--share=network', '--share=network',
'--socket=fallback-x11', '--socket=x11',
'--socket=wayland', '--socket=wayland',
'--device=dri', '--device=dri',
'--socket=pulseaudio',
'--filesystem=home' '--filesystem=home'
) )
} }
@ -524,12 +571,118 @@ function New-FlatpakDesktopEntry {
"Name=$AppName", "Name=$AppName",
"Exec=$Command", "Exec=$Command",
"Icon=$AppId", "Icon=$AppId",
"StartupWMClass=$AppId",
'StartupNotify=true',
'Terminal=false', 'Terminal=false',
"Categories=$Categories" "Categories=$Categories"
) )
return (($lines -join "`n") + "`n") return (($lines -join "`n") + "`n")
} }
function Copy-FlatpakHicolorIcons {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[Parameter(Mandatory = $true)]
[string]$HicolorRoot,
[Parameter(Mandatory = $false)]
[string]$IconPath,
[Parameter(Mandatory = $false)]
[string]$SvgIconPath
)
$pngSource = $null
$svgSource = $null
if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
$ext = [System.IO.Path]::GetExtension($IconPath)
if ($ext -ieq '.png') {
$pngSource = $IconPath
}
elseif ($ext -ieq '.svg') {
$svgSource = $IconPath
}
}
if (-not [string]::IsNullOrWhiteSpace($SvgIconPath) -and (Test-Path -LiteralPath $SvgIconPath -PathType Leaf)) {
$svgSource = $SvgIconPath
}
if ([string]::IsNullOrWhiteSpace($svgSource) -and -not [string]::IsNullOrWhiteSpace($pngSource)) {
$siblingSvg = [System.IO.Path]::ChangeExtension($pngSource, '.svg')
if (Test-Path -LiteralPath $siblingSvg -PathType Leaf) {
$svgSource = $siblingSvg
}
}
if ([string]::IsNullOrWhiteSpace($pngSource) -and -not [string]::IsNullOrWhiteSpace($svgSource)) {
$siblingPng = [System.IO.Path]::ChangeExtension($svgSource, '.png')
if (Test-Path -LiteralPath $siblingPng -PathType Leaf) {
$pngSource = $siblingPng
}
}
$installed = $false
if (-not [string]::IsNullOrWhiteSpace($pngSource)) {
foreach ($size in @('48x48', '64x64', '128x128', '256x256')) {
$pngDir = Join-Path $HicolorRoot "$size\apps"
New-Item -ItemType Directory -Path $pngDir -Force | Out-Null
Copy-Item -LiteralPath $pngSource -Destination (Join-Path $pngDir "$AppId.png") -Force
}
$installed = $true
}
if (-not [string]::IsNullOrWhiteSpace($svgSource)) {
$svgDir = Join-Path $HicolorRoot 'scalable\apps'
New-Item -ItemType Directory -Path $svgDir -Force | Out-Null
Copy-Item -LiteralPath $svgSource -Destination (Join-Path $svgDir "$AppId.svg") -Force
$installed = $true
}
return $installed
}
function ConvertTo-FlatpakMetainfoDescriptionXml {
param(
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Summary,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Description
)
$text = $Description
if ([string]::IsNullOrWhiteSpace($text)) {
$text = "$AppName is a desktop application. $Summary".Trim()
}
$paragraphs = [System.Collections.Generic.List[string]]::new()
foreach ($chunk in @($text -split '(\r?\n){2,}')) {
$trimmed = $chunk.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
continue
}
$paragraphs.Add(' <p>' + [System.Security.SecurityElement]::Escape($trimmed) + '</p>')
}
if ($paragraphs.Count -eq 0) {
$paragraphs.Add(' <p>' + [System.Security.SecurityElement]::Escape("$AppName is a desktop application.") + '</p>')
}
return ($paragraphs -join "`n")
}
function New-FlatpakMetainfoXml { function New-FlatpakMetainfoXml {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -541,26 +694,124 @@ function New-FlatpakMetainfoXml {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Summary, [string]$Summary,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Description = '',
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$ProjectLicense = 'MIT', [string]$ProjectLicense = 'MIT',
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$MetadataLicense = 'CC0-1.0' [string]$MetadataLicense = 'CC0-1.0',
[Parameter(Mandatory = $false)]
[string]$Version = ''
) )
$escapedName = [System.Security.SecurityElement]::Escape($AppName) $escapedName = [System.Security.SecurityElement]::Escape($AppName)
$escapedSummary = [System.Security.SecurityElement]::Escape($Summary) $escapedSummary = [System.Security.SecurityElement]::Escape($Summary)
return @" $descriptionXml = ConvertTo-FlatpakMetainfoDescriptionXml -AppName $AppName -Summary $Summary -Description $Description
$xml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application"> <component type="desktop-application">
<id>$AppId</id> <id>$AppId</id>
<name>$escapedName</name> <name>$escapedName</name>
<summary>$escapedSummary</summary> <summary>$escapedSummary</summary>
<description>
$descriptionXml
</description>
<launchable type="desktop-id">$AppId.desktop</launchable> <launchable type="desktop-id">$AppId.desktop</launchable>
<metadata_license>$MetadataLicense</metadata_license> <metadata_license>$MetadataLicense</metadata_license>
<project_license>$ProjectLicense</project_license> <project_license>$ProjectLicense</project_license>
</component> </component>
"@ "@
if ([string]::IsNullOrWhiteSpace($Version)) {
return $xml
}
return Set-FlatpakMetainfoRelease -XmlText $xml -Version $Version
}
function Set-FlatpakMetainfoRelease {
param(
[Parameter(Mandatory = $true)]
[string]$XmlText,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $false)]
[string]$Date = ''
)
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Set-FlatpakMetainfoRelease requires Version."
}
if ([string]::IsNullOrWhiteSpace($Date)) {
$Date = [datetime]::UtcNow.ToString('yyyy-MM-dd')
}
$doc = New-Object System.Xml.XmlDocument
$doc.PreserveWhitespace = $false
$doc.LoadXml($XmlText)
$component = $doc.SelectSingleNode('/component')
if ($null -eq $component) {
throw "Flatpak metainfo has no <component> root."
}
$releases = $component.SelectSingleNode('releases')
if ($null -eq $releases) {
$releases = $doc.CreateElement('releases')
$null = $component.AppendChild($releases)
}
$match = $null
foreach ($node in @($releases.SelectNodes('release'))) {
if ([string]$node.GetAttribute('version') -eq $Version) {
$match = $node
break
}
}
$first = $releases.SelectSingleNode('release')
if ($null -ne $match) {
$match.SetAttribute('date', $Date)
if (-not [object]::ReferenceEquals($match, $first)) {
$null = $releases.InsertBefore($match, $first)
}
}
else {
$release = $doc.CreateElement('release')
$release.SetAttribute('version', $Version)
$release.SetAttribute('date', $Date)
if ($null -eq $first) {
$null = $releases.AppendChild($release)
}
else {
$null = $releases.InsertBefore($release, $first)
}
}
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Indent = $true
$settings.OmitXmlDeclaration = $false
$settings.Encoding = [System.Text.UTF8Encoding]::new($false)
$stream = New-Object System.IO.MemoryStream
try {
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
try {
$doc.Save($writer)
}
finally {
$writer.Dispose()
}
return [System.Text.Encoding]::UTF8.GetString($stream.ToArray())
}
finally {
$stream.Dispose()
}
} }
function New-FlatpakManifestObject { function New-FlatpakManifestObject {
@ -597,8 +848,8 @@ function New-FlatpakManifestObject {
if ($null -eq $BuildCommands -or $BuildCommands.Count -eq 0) { if ($null -eq $BuildCommands -or $BuildCommands.Count -eq 0) {
$BuildCommands = @( $BuildCommands = @(
'mkdir -p /app/lib /app/share', 'mkdir -p /app/lib /app/share',
'cp -a lib /app/lib', 'cp -a lib/. /app/lib/',
'cp -a share /app/share', 'cp -a share/. /app/share/',
"install -Dm755 bin/$Command /app/bin/$Command", "install -Dm755 bin/$Command /app/bin/$Command",
"chmod +x /app/lib/$ModuleName/$Command" "chmod +x /app/lib/$ModuleName/$Command"
) )
@ -636,7 +887,15 @@ function New-FlatpakLaunchScript {
[string]$ExecutableFileName [string]$ExecutableFileName
) )
return "#!/bin/sh`nexec /app/lib/$ModuleName/$ExecutableFileName `"`$@`"`n" $lib = "/app/lib/$ModuleName"
return @"
#!/bin/sh
LIB='$lib'
if [ -f "`$LIB/flatpak-native.env" ]; then
. "`$LIB/flatpak-native.env"
fi
exec `$LIB/$ExecutableFileName "`$`@"
"@
} }
function Assert-FlatpakAppId { function Assert-FlatpakAppId {
@ -661,6 +920,27 @@ function Assert-FlatpakAppId {
} }
} }
function Get-DesktopShortcutComponentGuid {
param(
[Parameter(Mandatory = $true)]
[string]$Manufacturer,
[Parameter(Mandatory = $true)]
[string]$AppName
)
$identity = "maksit-desktop-shortcut|$Manufacturer|$AppName"
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$hash = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($identity))
}
finally {
$md5.Dispose()
}
return [guid]::new($hash)
}
function Get-DerivedBundleUpgradeCode { function Get-DerivedBundleUpgradeCode {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -983,15 +1263,18 @@ function New-WixBundleXml {
$folderRoot + $Manufacturer + '\' + $productFolder $folderRoot + $Manufacturer + '\' + $productFolder
} }
$escapedFolder = [System.Security.SecurityElement]::Escape($folderPath) $escapedFolder = [System.Security.SecurityElement]::Escape($folderPath)
$escapedShortcutKey = [System.Security.SecurityElement]::Escape("Software\$Manufacturer\$AppName")
# WiX v7 Bundle has no Scope attribute (WIX0004). MSI Package/@Scope plus # WiX v7 Bundle has no Scope attribute (WIX0004). MSI Package/@Scope plus
# InstallFolder tokens decide per-machine vs per-user; Burn infers bundle scope. # InstallFolder tokens decide per-machine vs per-user; Burn infers bundle scope.
return @" return @"
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal"> <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<Bundle Name="$escapedName" Manufacturer="$escapedMfr" Version="$ProductVersion" UpgradeCode="$($bundleUpgrade.ToString('D'))"$iconAttr> <Bundle Name="$escapedName" Manufacturer="$escapedMfr" Version="$ProductVersion" UpgradeCode="$($bundleUpgrade.ToString('D'))"$iconAttr>
<Variable Name="InstallFolder" Type="formatted" Value="$escapedFolder" bal:Overridable="yes" /> <Variable Name="InstallFolder" Type="formatted" Value="$escapedFolder" bal:Overridable="yes" />
<Variable Name="InstallDesktopShortcut" Type="numeric" Value="0" bal:Overridable="yes" /> <Variable Name="InstallDesktopShortcut" Type="numeric" Value="0" bal:Overridable="yes" />
<Variable Name="DesktopShortcutFound" Type="string" Value="" bal:Overridable="yes" />
<util:RegistrySearch Id="DesktopShortcutFoundSearch" Variable="DesktopShortcutFound" Root="HKCU" Key="$escapedShortcutKey" Value="desktop" Result="exists" />
<BootstrapperApplication> <BootstrapperApplication>
<bal:WixStandardBootstrapperApplication Theme="$theme" LicenseUrl=""$logoAttrs /> <bal:WixStandardBootstrapperApplication Theme="$theme" LicenseUrl=""$logoAttrs />
</BootstrapperApplication> </BootstrapperApplication>
@ -999,6 +1282,7 @@ function New-WixBundleXml {
<MsiPackage SourceFile="$escapedMsi" Compressed="yes" Vital="yes"> <MsiPackage SourceFile="$escapedMsi" Compressed="yes" Vital="yes">
<MsiProperty Name="INSTALLFOLDER" Value="[InstallFolder]" /> <MsiProperty Name="INSTALLFOLDER" Value="[InstallFolder]" />
<MsiProperty Name="INSTALLDESKTOPSHORTCUT" Value="[InstallDesktopShortcut]" /> <MsiProperty Name="INSTALLDESKTOPSHORTCUT" Value="[InstallDesktopShortcut]" />
<MsiProperty Name="DESKTOPSHORTCUTFOUND" Value="[DesktopShortcutFound]" />
</MsiPackage> </MsiPackage>
</Chain> </Chain>
</Bundle> </Bundle>
@ -1020,7 +1304,9 @@ Export-ModuleMember -Function `
New-WixBundleXml, ` New-WixBundleXml, `
Get-DefaultFlatpakFinishArgs, ` Get-DefaultFlatpakFinishArgs, `
New-FlatpakDesktopEntry, ` New-FlatpakDesktopEntry, `
Copy-FlatpakHicolorIcons, `
New-FlatpakMetainfoXml, ` New-FlatpakMetainfoXml, `
Set-FlatpakMetainfoRelease, `
New-FlatpakManifestObject, ` New-FlatpakManifestObject, `
New-FlatpakLaunchScript, ` New-FlatpakLaunchScript, `
Assert-FlatpakAppId, ` Assert-FlatpakAppId, `

View File

@ -13,6 +13,8 @@
(default Debian) on the Linux filesystem, then copies the bundle back. (default Debian) on the Linux filesystem, then copies the bundle back.
Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail
applies when neither native nor WSL builder is available. applies when neither native nor WSL builder is available.
AppStream <release version> and the bundle file name use the same shared
release version as zip/MSI (DotNetReleaseVersion: csproj or Directory.Build.props).
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -34,6 +36,37 @@ function Get-FlatpakWhenBuilderMissing {
return $value return $value
} }
function Resolve-FlatpakPackOptionalFile {
param(
[Parameter(Mandatory = $true)]
[string]$ScriptDir,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Setting,
[Parameter(Mandatory = $true)]
[string]$Name
)
if ([string]::IsNullOrWhiteSpace($Setting)) {
return $null
}
$path = if ([System.IO.Path]::IsPathRooted($Setting)) {
$Setting
}
else {
[System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Setting))
}
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "FlatpakPack $Name not found: $path"
}
return $path
}
function Test-FlatpakBuilderMissingException { function Test-FlatpakBuilderMissingException {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -188,6 +221,9 @@ function Invoke-Plugin {
$scriptDir = $sharedSettings.scriptDir $scriptDir = $sharedSettings.scriptDir
$version = [string]$sharedSettings.version $version = [string]$sharedSettings.version
$artifactsDirectory = $sharedSettings.artifactsDirectory $artifactsDirectory = $sharedSettings.artifactsDirectory
if ([string]::IsNullOrWhiteSpace($version)) {
throw "FlatpakPack requires a release version in the shared context (same as zip/MSI: DotNetReleaseVersion from csproj / Directory.Build.props)."
}
$appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId') $appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId')
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName') $appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
@ -206,9 +242,13 @@ function Invoke-Plugin {
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64') $runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64')
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir') $publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath') $iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
$svgIconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'svgIconPath')
$summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName) $summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName)
$description = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'description' -Default '')
$projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT') $projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT')
$categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;') $categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;')
$metainfoSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'metainfoPath' -Default '')
$desktopFileSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'desktopPath' -Default '')
$runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform') $runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform')
$runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08') $runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08')
$sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk') $sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk')
@ -264,8 +304,8 @@ function Invoke-Plugin {
$binDir = Join-Path $filesRoot 'bin' $binDir = Join-Path $filesRoot 'bin'
$shareAppDir = Join-Path $filesRoot 'share\applications' $shareAppDir = Join-Path $filesRoot 'share\applications'
$shareMetaDir = Join-Path $filesRoot 'share\metainfo' $shareMetaDir = Join-Path $filesRoot 'share\metainfo'
$shareIconDir = Join-Path $filesRoot 'share\icons\hicolor\256x256\apps' $hicolorRoot = Join-Path $filesRoot 'share\icons\hicolor'
New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $shareIconDir -Force | Out-Null New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $hicolorRoot -Force | Out-Null
Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory" Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory"
Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force
@ -274,20 +314,56 @@ function Invoke-Plugin {
$launchPath = Join-Path $binDir $command $launchPath = Join-Path $binDir $command
[System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
$desktopPath = Join-Path $shareAppDir "$appId.desktop" $desktopDest = Join-Path $shareAppDir "$appId.desktop"
[System.IO.File]::WriteAllText( $upstreamDesktop = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $desktopFileSetting -Name 'desktopPath'
$desktopPath, if ($null -ne $upstreamDesktop) {
(New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"), $desktopText = [System.IO.File]::ReadAllText($upstreamDesktop) -replace "`r`n", "`n" -replace "`r", "`n"
[System.Text.UTF8Encoding]::new($false) if (-not $desktopText.EndsWith("`n")) {
) $desktopText += "`n"
}
$metaPath = Join-Path $shareMetaDir "$appId.metainfo.xml" [System.IO.File]::WriteAllText($desktopDest, $desktopText, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText( }
$metaPath, else {
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -ProjectLicense $projectLicense).Replace("`r`n", "`n"), [System.IO.File]::WriteAllText(
[System.Text.UTF8Encoding]::new($false) $desktopDest,
) (New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"),
[System.Text.UTF8Encoding]::new($false)
)
}
$metaDest = Join-Path $shareMetaDir "$appId.metainfo.xml"
$upstreamMetainfo = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $metainfoSetting -Name 'metainfoPath'
if ($null -ne $upstreamMetainfo) {
$metaText = [System.IO.File]::ReadAllText($upstreamMetainfo) -replace "`r`n", "`n" -replace "`r", "`n"
if (-not $metaText.EndsWith("`n")) {
$metaText += "`n"
}
$metaText = $metaText.Replace('{version}', $version)
$metaText = Set-FlatpakMetainfoRelease -XmlText $metaText -Version $version
if (-not $metaText.EndsWith("`n")) {
$metaText += "`n"
}
[System.IO.File]::WriteAllText($metaDest, $metaText, [System.Text.UTF8Encoding]::new($false))
}
else {
[System.IO.File]::WriteAllText(
$metaDest,
(New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -Description $description -ProjectLicense $projectLicense -Version $version).Replace("`r`n", "`n"),
[System.Text.UTF8Encoding]::new($false)
)
}
$stagedMeta = [System.IO.File]::ReadAllText($metaDest)
if ($stagedMeta -notmatch [regex]::Escape("version=`"$version`"")) {
throw "FlatpakPack failed to stamp AppStream release version '$version' into $metaDest."
}
Write-Log -Level "OK" -Message " AppStream release version: $version"
$iconPath = $null
if (-not [string]::IsNullOrWhiteSpace($iconSetting)) { if (-not [string]::IsNullOrWhiteSpace($iconSetting)) {
$iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) { $iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) {
$iconSetting $iconSetting
@ -299,21 +375,39 @@ function Invoke-Plugin {
if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) { if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
throw "FlatpakPack iconPath not found: $iconPath" throw "FlatpakPack iconPath not found: $iconPath"
} }
}
if ([System.IO.Path]::GetExtension($iconPath) -ine '.png') { $svgIconPath = $null
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG; skipping copy of $iconPath" if (-not [string]::IsNullOrWhiteSpace($svgIconSetting)) {
$svgIconPath = if ([System.IO.Path]::IsPathRooted($svgIconSetting)) {
$svgIconSetting
} }
else { else {
Copy-Item -LiteralPath $iconPath -Destination (Join-Path $shareIconDir "$appId.png") -Force [System.IO.Path]::GetFullPath((Join-Path $scriptDir $svgIconSetting))
} }
if (-not (Test-Path -LiteralPath $svgIconPath -PathType Leaf)) {
throw "FlatpakPack svgIconPath not found: $svgIconPath"
}
}
$iconInstalled = Copy-FlatpakHicolorIcons `
-AppId $appId `
-HicolorRoot $hicolorRoot `
-IconPath $iconPath `
-SvgIconPath $svgIconPath
if (-not $iconInstalled -and -not [string]::IsNullOrWhiteSpace($iconPath)) {
Write-Log -Level "WARN" -Message " Flatpak icon should be PNG or SVG; skipping copy of $iconPath"
} }
$buildCommands = @( $buildCommands = @(
'mkdir -p /app/lib /app/share', 'mkdir -p /app/lib /app/share',
"cp -a lib/$moduleName /app/lib/$moduleName", "cp -a lib/$moduleName /app/lib/$moduleName",
'cp -a share /app/share', 'cp -a share/. /app/share/',
"install -Dm755 bin/$command /app/bin/$command", "install -Dm755 bin/$command /app/bin/$command",
"chmod +x /app/lib/$moduleName/$executableFileName" "chmod +x /app/lib/$moduleName/$executableFileName",
'gtk-update-icon-cache -f /app/share/icons/hicolor >/dev/null 2>&1 || true'
) )
$manifest = New-FlatpakManifestObject ` $manifest = New-FlatpakManifestObject `

View File

@ -13,7 +13,8 @@
per-machine installs go to `C:\Program Files`, not Program Files (x86). per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext`. `wix extension add -g WixToolset.BootstrapperApplications.wixext` and
`wix extension add -g WixToolset.Util.wixext`.
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH. Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
#> #>
@ -40,7 +41,7 @@ function Test-WixMissingException {
} }
function Get-WixMissingInstallMessage { function Get-WixMissingInstallMessage {
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH." return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext; wix extension add -g WixToolset.Util.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
} }
function Get-WixCliVersionText { function Get-WixCliVersionText {
@ -280,7 +281,7 @@ function Invoke-Plugin {
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..." Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try { try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-ext', 'WixToolset.Util.wixext', '-o', $exePath)) | Out-Null
} }
catch { catch {
if (Test-WixMissingException -ErrorRecord $_) { if (Test-WixMissingException -ErrorRecord $_) {

View File

@ -1,89 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Builds an npm workspace (install + build script).
.DESCRIPTION
Runs npm ci (or npm install when useCi is false) and npm run build in the
configured workspace root. Requires NpmReleaseVersion to have set
shared npmWorkspaceRoot unless workspaceRoot is configured explicitly.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$useCi = $true
if ($null -ne $pluginSettings.useCi) {
$useCi = [bool]$pluginSettings.useCi
}
$buildScript = 'build'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) {
$buildScript = [string]$pluginSettings.buildScript
}
Push-Location $workspaceRoot
try {
if ($useCi) {
Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..."
npm ci
if ($LASTEXITCODE -ne 0) {
throw "npm ci failed with exit code $LASTEXITCODE."
}
}
else {
Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..."
npm install
if ($LASTEXITCODE -ne 0) {
throw "npm install failed with exit code $LASTEXITCODE."
}
}
Write-Log -Level "STEP" -Message "Running npm run $buildScript..."
npm run $buildScript
if ($LASTEXITCODE -ne 0) {
throw "npm run $buildScript failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message " npm build completed."
}
finally {
Pop-Location
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,75 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
npm/Jest test plugin for the test engine.
.DESCRIPTION
Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
normalized metrics on the shared engine context for downstream plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
Assert-Command npm
if (-not $pluginSettings.workspaceRoot) {
throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json."
}
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
$workspaceRoot = $workspaceRoots[0]
$testScript = 'test'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
$testScript = [string]$pluginSettings.testScript
}
$coverageDirectory = 'coverage'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
$coverageDirectory = [string]$pluginSettings.coverageDirectory
}
$testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext"
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) {
Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile'
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,139 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Packs npm workspace packages into .tgz release artifacts.
.DESCRIPTION
Runs npm pack for each configured workspace package and publishes the
resulting tarball paths into shared release context (releaseAssetPaths)
for the GitHub release plugin.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$artifactsDirectory = $null
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir)))
Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory
}
elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) {
$artifactsDirectory = [string]$shared.artifactsDirectory
}
else {
throw "NpmPack plugin requires release-stage artifactsDirectory."
}
$packOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$packOrder = @([string]$pluginSettings.publishOrder)
}
}
elseif ($pluginSettings.packOrder) {
if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) {
$packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) {
$packOrder = @([string]$pluginSettings.packOrder)
}
}
if ($packOrder.Count -eq 0) {
throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)."
}
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $packOrder.Count -gt 1) {
throw "NpmPack plugin requires npm workspaces when packing more than one package."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$releaseAssetPaths = @()
Push-Location $workspaceRoot
try {
foreach ($packageName in $packOrder) {
Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..."
if ($useWorkspaces) {
$tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
$tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) {
throw "npm pack failed for '$packageName'."
}
$tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim()
if (-not (Test-Path $tarballPath -PathType Leaf)) {
throw "Could not locate pack output for '$packageName' at: $tarballPath"
}
$strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim()
if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) {
Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue
}
Write-Log -Level "OK" -Message " Package ready: $tarballPath"
$releaseAssetPaths += $tarballPath
}
}
finally {
Pop-Location
}
Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if ($releaseAssetPaths.Count -gt 0) {
$packageItem = Get-Item -LiteralPath $releaseAssetPaths[0]
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile'
}
Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,62 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-NpmRootPackageJson {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$packageJsonPath = Join-Path $WorkspaceRoot 'package.json'
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
throw "package.json not found at '$packageJsonPath'."
}
return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Test-NpmWorkspacesConfigured {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) {
return $false
}
$workspaces = $json.workspaces
if ($workspaces -is [string]) {
return -not [string]::IsNullOrWhiteSpace($workspaces)
}
if ($workspaces -is [System.Collections.IEnumerable]) {
$entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
return $entries.Count -gt 0
}
return $false
}
function Assert-NpmRootPackageName {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[Parameter(Mandatory = $true)]
[string]$ExpectedPackageName
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
$rootPackageName = [string]$json.name
if ([string]::IsNullOrWhiteSpace($rootPackageName)) {
throw "Root package.json at '$WorkspaceRoot' is missing 'name'."
}
if ($rootPackageName -ne $ExpectedPackageName) {
throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)."
}
}
Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson

View File

@ -1,158 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using RepoUtilsSecrets slot Npm.
Uses a temporary .npmrc in the workspace root.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
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
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
'https://registry.npmjs.org'
}
else {
[string]$pluginSettings.registry
}
$access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
'public'
}
else {
[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])) {
$publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$publishOrder = @([string]$pluginSettings.publishOrder)
}
}
if ($publishOrder.Count -eq 0) {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
throw "NpmPublish plugin requires npm workspaces when publishing more than one package."
}
if ($dryRun) {
foreach ($packageName in $publishOrder) {
$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
}
$npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
if ([string]::IsNullOrWhiteSpace($npmToken)) {
throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)."
}
$registryHost = ([uri]$registry).Host
$tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp"
$npmRcContent = @"
registry=$registry
//$registryHost/:_authToken=$npmToken
"@
Push-Location $workspaceRoot
try {
Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
$publishArgs += @('-w', $packageName)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
}
$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'."
}
Write-Log -Level "OK" -Message " Published $packageName."
}
Write-Log -Level "OK" -Message " npm publish completed."
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
}
finally {
if (Test-Path $tempNpmRcPath -PathType Leaf) {
Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
}
Pop-Location
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $true }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -1,106 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Loads npm workspace release version into shared context.
.DESCRIPTION
Reads semver from the configured workspace package.json and writes it to
shared context version. Optionally synchronizes version fields across
workspace package manifests before build/publish.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath
)
if (-not (Test-Path $PackageJsonPath -PathType Leaf)) {
throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'."
}
$json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
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
}
function Set-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath,
[Parameter(Mandatory = $true)]
[string]$Version
)
$raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8
$json = $raw | ConvertFrom-Json
$json.version = $Version
($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline
}
function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true }
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
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
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
if ($packageJsonPaths.Count -eq 0) {
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json."
}
$packageJsonPath = $packageJsonPaths[0]
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
$syncWorkspaceVersions = $false
if ($null -ne $pluginSettings.syncWorkspaceVersions) {
$syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions
}
if ($syncWorkspaceVersions) {
$workspaceRoot = Split-Path -Parent $packageJsonPath
$packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue
foreach ($manifest in $packageManifests) {
Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version
}
Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version."
}
$npmWorkspaceRoot = Split-Path -Parent $packageJsonPath
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath'
Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version"
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,78 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Collects coverage metrics from an existing test results directory.
.DESCRIPTION
Stack-agnostic parser for recovered container or host test output. Auto-detects .NET
Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the
same shared-context keys as DotNetTest / NpmJestTest for QualityGate.
Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory'
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$resultsDirSetting = $pluginSettings.resultsDir
if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) {
if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) {
$resultsDirSetting = $sharedSettings.testResultsDirectory
}
else {
throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)."
}
}
$resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) {
[string]$resultsDirSetting
}
else {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting)))
}
$format = 'auto'
if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) {
$format = [string]$pluginSettings.format
}
Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..."
$testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true
if (-not $testResult.Success) {
throw $testResult.Error
}
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
$formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' }
Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)."
Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,52 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Probes docker/podman availability and sets mandatory shared engine state for the pipeline.
.DESCRIPTION
Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine,
dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context.
Fails the pipeline when no usable engine is found.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState'
$shared = $Settings.context
Write-Log -Level 'STEP' -Message 'Probing container engine availability...'
$state = Resolve-ActiveContainerEngineState
Set-ContainerEngineSharedState -SharedSettings $shared -State $state
$display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared)
Write-Log -Level 'OK' -Message " Active engine: $display"
Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)"
if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) {
Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).'
}
else {
Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')"
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,124 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Discovers package artifacts produced by pack plugins or container build pipelines.
.DESCRIPTION
Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files
and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = $sharedSettings.version
if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir)))
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
else {
$sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
}
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.'
}
$nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object {
$_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg'
})
$tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue)
if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) {
throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository."
}
if ($nupkgCandidates.Count -gt 0) {
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.'
}
Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..."
$resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)"
if ($resolved.SymbolsPackageFile) {
Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)"
}
else {
Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
return
}
if ($tgzCandidates.Count -gt 0) {
Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts'
Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..."
$resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
foreach ($tarball in $resolved.PackageFiles) {
Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)"
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
}
return
}
throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata