diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19fac68..aff6157 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Thank you for your interest in contributing to MaksIT.UScheduler! 2. Open the solution in Visual Studio or your preferred IDE: ``` - src/MaksIT.UScheduler/MaksIT.UScheduler.sln + src/MaksIT.UScheduler.slnx ``` 3. Build the project: diff --git a/README.md b/README.md index 9813a7f..94feba6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # MaksIT Unified Scheduler Service -![Line Coverage](badges/coverage-lines.svg) ![Branch Coverage](badges/coverage-branches.svg) ![Method Coverage](badges/coverage-methods.svg) +![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) A modern, fully rewritten Windows service built on **.NET 10** for scheduling and running PowerShell scripts and console applications. Designed for system administrators — and also for those who *feel like* system administrators — who need a predictable, resilient, and secure background execution environment. +> **Tip:** A graphical [Schedule Manager UI](#schedule-manager-ui) is included for easy service registration, script scheduling, and log viewing — no command-line required. + --- ## Table of Contents @@ -16,6 +18,12 @@ Designed for system administrators — and also for those who *feel like* system - [Installation](#installation) - [Using CLI Commands](#using-cli-commands) - [Using sc.exe](#using-scexe) + - [Schedule Manager UI](#schedule-manager-ui) + - [Getting Started](#getting-started) + - [Settings View](#settings-view) + - [Main View — Schedule Management](#main-view--schedule-management) + - [Service Logs View](#service-logs-view) + - [Script Logs View](#script-logs-view) - [Configuration (`appsettings.json`)](#configuration-appsettingsjson) - [Path Resolution](#path-resolution) - [Log Levels](#log-levels) @@ -52,6 +60,7 @@ Designed for system administrators — and also for those who *feel like* system ## Features at a Glance * **.NET 10 Worker Service** – clean, robust, stable. +* **Fully portable** – relocate between machines without reconfiguration. * **Windows only** – designed specifically for Windows services. * **Strongly typed configuration** via `appsettings.json`. * **Parallel execution** – PowerShell scripts & executables run concurrently using RunspacePool and Task.WhenAll. @@ -120,6 +129,96 @@ sc.exe delete "MaksIT.UScheduler" --- +## Schedule Manager UI + +The Schedule Manager is a WPF application that provides a graphical interface for managing the UScheduler service and its scheduled scripts. + +### Getting Started + +When you download and unpack the release bundle, launch `Start-ScheduleManager.bat` as administrator. + +![Manager launcher](./assets/explorer_6Ai8GBZ7xg.png) + +> **Note:** Administrator privileges are required only for service management operations (register, start, stop, unregister). Regular schedule editing can be done without elevation. + +### Settings View + +The Settings view is your starting point for configuring the Schedule Manager. + +![Settings view](./assets/MaksIT.UScheduler.ScheduleManager_aYFXXtK8V2.png) + +| Feature | Description | +|---------|-------------| +| **Service Bin Path** | Path to the UScheduler installation folder containing `MaksIT.UScheduler.exe` | +| **Service Status** | Real-time status indicator (Running, Stopped, Starting, Stopping, Paused, Not Installed) | +| **Register/Unregister** | Install or remove the Windows service (requires admin) | +| **Start/Stop** | Control the service state (requires admin) | +| **Refresh** | Update the current service status display | +| **Reload Settings** | Refresh service configuration from `appsettings.json` | + +### Main View — Schedule Management + +The Main view allows you to manage script schedules and execution settings. + +![Main view](./assets/MaksIT.UScheduler.ScheduleManager_M7ZQAkaymD.png) + +**Script List Panel:** +- Lists all PowerShell scripts configured in `appsettings.json` +- Select a script to view and edit its schedule + +**Script Configuration:** + +| Setting | Description | +|---------|-------------| +| **Name** | Display name for the script | +| **Is Signed** | Require script to be digitally signed (AllSigned policy) | +| **Disabled** | Skip this script during scheduled execution | + +**Schedule Configuration:** + +| Setting | Description | +|---------|-------------| +| **Run Month** | Select specific months to run (empty = every month) | +| **Run Weekday** | Select specific days of the week (empty = every day) | +| **Run Time** | Add/remove specific execution times (HH:mm format) | +| **Min Interval** | Minimum minutes between executions (prevents duplicate runs) | + +**Actions:** +- **Save** — Persist schedule changes to `scriptsettings.json` +- **Revert** — Discard unsaved changes +- **Launch** — Execute the script immediately via its `.bat` file + +**Script Status:** +- View lock file status (indicates if script is currently running) +- View last execution timestamp +- Remove stale lock files from crashed scripts + +### Service Logs View + +Monitor the UScheduler service activity and troubleshoot issues. + +![Logs view](./assets/MaksIT.UScheduler.ScheduleManager_MiY7biadQg.png) + +Features: +- Browse service log files sorted by date +- View log content directly in the application +- Open log files in Windows Explorer +- Refresh logs to see latest entries + +### Script Logs View + +View execution logs for individual scheduled scripts. + +![Script logs view](./assets/MaksIT.UScheduler.ScheduleManager_HjRiCd1jnn.png) + +Features: +- Browse log folders organized by script name +- Select and view individual log files +- Track script execution history and errors +- Open logs in Explorer for external tools + + + ## Configuration (`appsettings.json`) ```json @@ -142,12 +241,12 @@ sc.exe delete "MaksIT.UScheduler" "LogDir": "C:\\Logs", "Powershell": [ - { "Path": "../Scripts/MyScript.ps1", "IsSigned": true, "Disabled": false }, + { "Path": "..\\Scripts\\MyScript.ps1", "IsSigned": true, "Disabled": false }, { "Path": "C:\\Scripts\\AnotherScript.ps1", "IsSigned": false, "Disabled": true } ], "Processes": [ - { "Path": "../Tools/MyApp.exe", "Args": ["--option"], "RestartOnFailure": true, "Disabled": false } + { "Path": "..\\Tools\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true, "Disabled": false } ] } } @@ -373,16 +472,13 @@ dotnet tool install --global dotnet-reportgenerator-globaltool ## Contact -Maksym Sadovnychyy – MAKS-IT, 2025 +**Maksym Sadovnychyy** — [MAKS-IT](https://github.com/MAKS-IT-COM) Email: maksym.sadovnychyy@gmail.com --- ## License -MIT License -Copyright (c) 2025 -Maksym Sadovnychyy – MAKS-IT -maksym.sadovnychyy@gmail.com +This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details. --- diff --git a/assets/MaksIT.UScheduler.ScheduleManager_HjRiCd1jnn.png b/assets/MaksIT.UScheduler.ScheduleManager_HjRiCd1jnn.png new file mode 100644 index 0000000..63551f5 Binary files /dev/null and b/assets/MaksIT.UScheduler.ScheduleManager_HjRiCd1jnn.png differ diff --git a/assets/MaksIT.UScheduler.ScheduleManager_M7ZQAkaymD.png b/assets/MaksIT.UScheduler.ScheduleManager_M7ZQAkaymD.png new file mode 100644 index 0000000..001ec7c Binary files /dev/null and b/assets/MaksIT.UScheduler.ScheduleManager_M7ZQAkaymD.png differ diff --git a/assets/MaksIT.UScheduler.ScheduleManager_MiY7biadQg.png b/assets/MaksIT.UScheduler.ScheduleManager_MiY7biadQg.png new file mode 100644 index 0000000..9ae08e7 Binary files /dev/null and b/assets/MaksIT.UScheduler.ScheduleManager_MiY7biadQg.png differ diff --git a/assets/MaksIT.UScheduler.ScheduleManager_aYFXXtK8V2.png b/assets/MaksIT.UScheduler.ScheduleManager_aYFXXtK8V2.png new file mode 100644 index 0000000..c0b86e2 Binary files /dev/null and b/assets/MaksIT.UScheduler.ScheduleManager_aYFXXtK8V2.png differ diff --git a/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg similarity index 89% rename from badges/coverage-branches.svg rename to assets/badges/coverage-branches.svg index 4a0fa25..0516e60 100644 --- a/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,4 +1,4 @@ - + Branch Coverage: 7% diff --git a/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg similarity index 89% rename from badges/coverage-lines.svg rename to assets/badges/coverage-lines.svg index f5a3014..9adecb2 100644 --- a/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,4 +1,4 @@ - + Line Coverage: 15.2% diff --git a/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg similarity index 89% rename from badges/coverage-methods.svg rename to assets/badges/coverage-methods.svg index 7e83000..c0678d5 100644 --- a/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,4 +1,4 @@ - + Method Coverage: 38.8% diff --git a/assets/explorer_6Ai8GBZ7xg.png b/assets/explorer_6Ai8GBZ7xg.png new file mode 100644 index 0000000..9bb6ee9 Binary files /dev/null and b/assets/explorer_6Ai8GBZ7xg.png differ diff --git a/src/MaksIT.PSScriptGateway/Configuration/PSScriptGatewayOptions.cs b/src/MaksIT.PSScriptGateway/Configuration/PSScriptGatewayOptions.cs new file mode 100644 index 0000000..ec919a3 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Configuration/PSScriptGatewayOptions.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PSScriptGateway.Configuration; + +public sealed class PSScriptGatewayOptions +{ + public const string SectionName = "PSScriptGateway"; + + public string ScriptsRoot { get; set; } = @"..\..\..\..\Scripts"; +} diff --git a/src/MaksIT.PSScriptGateway/Controllers/PSScriptController.cs b/src/MaksIT.PSScriptGateway/Controllers/PSScriptController.cs new file mode 100644 index 0000000..52fa619 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Controllers/PSScriptController.cs @@ -0,0 +1,65 @@ +using MaksIT.PSScriptGateway.Models; +using MaksIT.PSScriptGateway.Services; +using Microsoft.AspNetCore.Mvc; + +namespace MaksIT.PSScriptGateway.Controllers; + +[ApiController] +[Route("api/scripts")] +public sealed class PSScriptController : ControllerBase +{ + private readonly IPSScriptGatewayService _scriptGatewayService; + + public PSScriptController(IPSScriptGatewayService scriptGatewayService) + { + _scriptGatewayService = scriptGatewayService; + } + + [AcceptVerbs("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")] + [Route("{**scriptName}")] + public async Task Execute(string scriptName, CancellationToken cancellationToken) + { + var request = await BuildRequestAsync(scriptName, cancellationToken); + var response = await _scriptGatewayService.ExecuteAsync(scriptName, request, cancellationToken); + return ResultMapper.ToActionResult(response.StatusCode, response.Value, response.Messages); + } + + private async Task BuildRequestAsync(string scriptName, CancellationToken cancellationToken) + { + var body = await ReadBodyAsync(cancellationToken); + + return new ScriptExecutionRequest( + Request.Method, + Request.Path.Value ?? string.Empty, + scriptName, + Request.ContentType, + body, + Request.Query.ToDictionary( + pair => pair.Key, + pair => pair.Value.Select(static value => value ?? string.Empty).ToArray(), + StringComparer.OrdinalIgnoreCase), + Request.Headers.ToDictionary( + pair => pair.Key, + pair => pair.Value.Select(static value => value ?? string.Empty).ToArray(), + StringComparer.OrdinalIgnoreCase), + RouteData.Values.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.OrdinalIgnoreCase)); + } + + private async Task ReadBodyAsync(CancellationToken cancellationToken) + { + if (Request.ContentLength is null or 0) + return null; + + Request.EnableBuffering(); + Request.Body.Position = 0; + + using var reader = new StreamReader(Request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(cancellationToken); + Request.Body.Position = 0; + + return string.IsNullOrWhiteSpace(body) ? null : body; + } +} diff --git a/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.csproj b/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.csproj new file mode 100644 index 0000000..9ddeefe --- /dev/null +++ b/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.http b/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.http new file mode 100644 index 0000000..f6eaba9 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.http @@ -0,0 +1,6 @@ +@MaksIT.PSScriptGateway_HostAddress = http://localhost:5078 + +GET {{MaksIT.PSScriptGateway_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/MaksIT.PSScriptGateway/Models/ScriptExecutionRequest.cs b/src/MaksIT.PSScriptGateway/Models/ScriptExecutionRequest.cs new file mode 100644 index 0000000..396a6ce --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Models/ScriptExecutionRequest.cs @@ -0,0 +1,12 @@ +namespace MaksIT.PSScriptGateway.Models; + +public sealed record ScriptExecutionRequest( + string HttpMethod, + string RequestPath, + string ScriptPath, + string? ContentType, + string? Body, + IReadOnlyDictionary Query, + IReadOnlyDictionary Headers, + IReadOnlyDictionary RouteValues +); diff --git a/src/MaksIT.PSScriptGateway/Models/ScriptExecutionResponse.cs b/src/MaksIT.PSScriptGateway/Models/ScriptExecutionResponse.cs new file mode 100644 index 0000000..dd3c67a --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Models/ScriptExecutionResponse.cs @@ -0,0 +1,7 @@ +namespace MaksIT.PSScriptGateway.Models; + +public sealed record ScriptExecutionResponse( + int StatusCode, + object? Value, + IReadOnlyList Messages +); diff --git a/src/MaksIT.PSScriptGateway/Program.cs b/src/MaksIT.PSScriptGateway/Program.cs new file mode 100644 index 0000000..92e22b7 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Program.cs @@ -0,0 +1,15 @@ +using MaksIT.PSScriptGateway.Configuration; +using MaksIT.PSScriptGateway.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure( + builder.Configuration.GetSection(PSScriptGatewayOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); + +app.Run(); diff --git a/src/MaksIT.PSScriptGateway/Properties/launchSettings.json b/src/MaksIT.PSScriptGateway/Properties/launchSettings.json new file mode 100644 index 0000000..73449ff --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5078", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/MaksIT.PSScriptGateway/Services/IPSScriptGatewayService.cs b/src/MaksIT.PSScriptGateway/Services/IPSScriptGatewayService.cs new file mode 100644 index 0000000..09a60cc --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Services/IPSScriptGatewayService.cs @@ -0,0 +1,8 @@ +using MaksIT.PSScriptGateway.Models; + +namespace MaksIT.PSScriptGateway.Services; + +public interface IPSScriptGatewayService +{ + Task ExecuteAsync(string scriptName, ScriptExecutionRequest request, CancellationToken cancellationToken); +} diff --git a/src/MaksIT.PSScriptGateway/Services/PSScriptGatewayService.cs b/src/MaksIT.PSScriptGateway/Services/PSScriptGatewayService.cs new file mode 100644 index 0000000..0096f5c --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Services/PSScriptGatewayService.cs @@ -0,0 +1,192 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.Management.Automation; +using MaksIT.PSScriptGateway.Configuration; +using MaksIT.PSScriptGateway.Models; +using MaksIT.UScheduler.Shared.Helpers; +using Microsoft.Extensions.Options; + +namespace MaksIT.PSScriptGateway.Services; + +public sealed class PSScriptGatewayService : IPSScriptGatewayService +{ + private readonly ILogger _logger; + private readonly string _scriptsRoot; + + public PSScriptGatewayService( + ILogger logger, + IOptions options) + { + _logger = logger; + _scriptsRoot = ResolveScriptsRoot(options.Value.ScriptsRoot); + } + + public async Task ExecuteAsync( + string scriptName, + ScriptExecutionRequest request, + CancellationToken cancellationToken) + { + var scriptPath = ResolveScriptPath(scriptName); + if (scriptPath is null) + return new ScriptExecutionResponse(StatusCodes.Status404NotFound, null, ["Script not found."]); + + using var powerShell = PowerShell.Create(); + using var registration = cancellationToken.Register(() => { + try { + powerShell.Stop(); + } + catch (ObjectDisposedException) { + } + catch (InvalidOperationException) { + } + }); + + powerShell.AddCommand(scriptPath) + .AddParameter("Request", request) + .AddParameter("HttpMethod", request.HttpMethod) + .AddParameter("RequestPath", request.RequestPath) + .AddParameter("ScriptPath", request.ScriptPath) + .AddParameter("ContentType", request.ContentType) + .AddParameter("Body", request.Body) + .AddParameter("Query", request.Query) + .AddParameter("Headers", request.Headers) + .AddParameter("RouteValues", request.RouteValues); + + Collection output; + + try { + output = await Task.Run(() => powerShell.Invoke(), cancellationToken); + } + catch (OperationCanceledException) { + return new ScriptExecutionResponse(StatusCodes.Status499ClientClosedRequest, null, ["The request was canceled."]); + } + catch (RuntimeException exception) { + _logger.LogError(exception, "PowerShell runtime error while executing {ScriptPath}", scriptPath); + return new ScriptExecutionResponse(StatusCodes.Status500InternalServerError, null, [exception.Message]); + } + catch (Exception exception) { + _logger.LogError(exception, "Unhandled error while executing {ScriptPath}", scriptPath); + return new ScriptExecutionResponse(StatusCodes.Status500InternalServerError, null, ["Unhandled script execution error."]); + } + + if (TryParseScriptResponse(output, out var response)) + return response; + + if (powerShell.HadErrors) { + var errors = powerShell.Streams.Error + .Select(error => error.ToString()) + .Where(message => !string.IsNullOrWhiteSpace(message)) + .ToArray(); + + return new ScriptExecutionResponse( + StatusCodes.Status500InternalServerError, + null, + errors.Length == 0 ? ["Script execution failed."] : errors); + } + + return new ScriptExecutionResponse(StatusCodes.Status204NoContent, null, ["No content."]); + } + + private string ResolveScriptsRoot(string scriptsRoot) + { + var resolvedRoot = PathHelper.ResolvePath(scriptsRoot); + return Path.GetFullPath(resolvedRoot); + } + + private string? ResolveScriptPath(string scriptName) + { + var relativePath = scriptName + .Replace('/', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar); + + if (string.IsNullOrWhiteSpace(relativePath)) + return null; + + if (!relativePath.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + relativePath += ".ps1"; + + var combinedPath = Path.GetFullPath(Path.Combine(_scriptsRoot, relativePath)); + if (!combinedPath.StartsWith(_scriptsRoot, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(combinedPath) ? combinedPath : null; + } + + private static bool TryParseScriptResponse(Collection output, out ScriptExecutionResponse response) + { + response = default!; + + if (output.Count == 0) + return false; + + if (TryParseExplicitResponse(output[0], out response)) + return true; + + var values = output.Select(UnwrapValue).ToArray(); + var payload = values.Length == 1 ? values[0] : values; + response = new ScriptExecutionResponse(StatusCodes.Status200OK, payload, ["OK"]); + return true; + } + + private static bool TryParseExplicitResponse(PSObject psObject, out ScriptExecutionResponse response) + { + response = default!; + + if (!TryReadIntProperty(psObject, "StatusCode", out var statusCode)) + return false; + + var messages = ReadMessages(psObject); + var value = ReadProperty(psObject, "Value") + ?? ReadProperty(psObject, "Body") + ?? ReadProperty(psObject, "Data") + ?? ReadProperty(psObject, "Result"); + + response = new ScriptExecutionResponse(statusCode, value, messages); + return true; + } + + private static object? ReadProperty(PSObject psObject, string propertyName) + { + var property = psObject.Properties[propertyName]; + return property is null ? null : UnwrapValue(property.Value); + } + + private static IReadOnlyList ReadMessages(PSObject psObject) + { + var property = psObject.Properties["Messages"]; + if (property?.Value is null) + return []; + + if (property.Value is string message) + return [message]; + + if (property.Value is IEnumerable enumerable) { + return enumerable + .Cast() + .Select(item => item?.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Cast() + .ToArray(); + } + + return [property.Value.ToString() ?? "Script response."]; + } + + private static bool TryReadIntProperty(PSObject psObject, string propertyName, out int value) + { + value = default; + var property = psObject.Properties[propertyName]; + if (property?.Value is null) + return false; + + return int.TryParse(property.Value.ToString(), out value); + } + + private static object? UnwrapValue(object? value) + { + if (value is PSObject psObject) + return psObject.BaseObject; + + return value; + } +} diff --git a/src/MaksIT.PSScriptGateway/Services/ResultMapper.cs b/src/MaksIT.PSScriptGateway/Services/ResultMapper.cs new file mode 100644 index 0000000..dea5206 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/Services/ResultMapper.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Reflection; +using MaksIT.Results; +using Microsoft.AspNetCore.Mvc; + +namespace MaksIT.PSScriptGateway.Services; + +internal static class ResultMapper +{ + private static readonly Type GenericResultType = typeof(Result); + private static readonly Type NonGenericResultType = typeof(Result); + private static readonly IReadOnlyDictionary StatusMethodNames = Enum + .GetValues() + .Distinct() + .ToDictionary(code => (int)code, code => code.ToString()); + + public static IActionResult ToActionResult(int statusCode, object? value, IReadOnlyList messages) + { + var normalizedStatusCode = NormalizeStatusCode(statusCode); + var resolvedMessages = messages.Count == 0 + ? [GetDefaultMessage(normalizedStatusCode)] + : messages.ToArray(); + + if (TryBuildGenericResult(normalizedStatusCode, value, resolvedMessages, out var genericResult)) + return genericResult.ToActionResult(); + + if (value is null && TryBuildResult(normalizedStatusCode, resolvedMessages, out var result)) + return result.ToActionResult(); + + if (value is null) + return new StatusCodeResult(normalizedStatusCode); + + return new ObjectResult(value) { StatusCode = normalizedStatusCode }; + } + + private static bool TryBuildGenericResult(int statusCode, object? value, string[] messages, out Result result) + { + result = null!; + + if (!StatusMethodNames.TryGetValue(statusCode, out var methodName)) + return false; + + var method = GenericResultType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(current => + current.Name == methodName && + Matches(current.GetParameters(), typeof(object), typeof(string[]))); + + if (method is null) + return false; + + if (method.Invoke(null, [value, messages]) is not Result invoked) + return false; + + result = invoked; + return true; + } + + private static bool TryBuildResult(int statusCode, string[] messages, out Result result) + { + result = null!; + + if (!StatusMethodNames.TryGetValue(statusCode, out var methodName)) + return false; + + var method = NonGenericResultType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(current => + current.Name == methodName && + Matches(current.GetParameters(), typeof(string[]))); + + if (method is null) + return false; + + if (method.Invoke(null, [messages]) is not Result invoked) + return false; + + result = invoked; + return true; + } + + private static bool Matches(ParameterInfo[] parameters, params Type[] parameterTypes) + { + if (parameters.Length != parameterTypes.Length) + return false; + + for (var index = 0; index < parameters.Length; index++) { + if (parameters[index].ParameterType != parameterTypes[index]) + return false; + } + + return true; + } + + private static int NormalizeStatusCode(int statusCode) + { + return statusCode is >= 100 and <= 599 + ? statusCode + : StatusCodes.Status500InternalServerError; + } + + private static string GetDefaultMessage(int statusCode) + { + return StatusMethodNames.TryGetValue(statusCode, out var methodName) + ? methodName + : "Request completed."; + } +} diff --git a/src/MaksIT.PSScriptGateway/appsettings.Development.json b/src/MaksIT.PSScriptGateway/appsettings.Development.json new file mode 100644 index 0000000..e41c864 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "PSScriptGateway": { + "ScriptsRoot": "..\\..\\..\\..\\Scripts" + } +} diff --git a/src/MaksIT.PSScriptGateway/appsettings.json b/src/MaksIT.PSScriptGateway/appsettings.json new file mode 100644 index 0000000..7e31da6 --- /dev/null +++ b/src/MaksIT.PSScriptGateway/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "PSScriptGateway": { + "ScriptsRoot": "..\\..\\..\\..\\Scripts" + }, + "AllowedHosts": "*" +} diff --git a/src/MaksIT.UScheduler.sln b/src/MaksIT.UScheduler.sln deleted file mode 100644 index c8eddd7..0000000 --- a/src/MaksIT.UScheduler.sln +++ /dev/null @@ -1,42 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.0.11222.15 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler.Tests", "MaksIT.UScheduler.Tests\MaksIT.UScheduler.Tests.csproj", "{DC193ABC-89F8-131B-060F-6C3A3CE2652A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.ScheduleManager", "MaksIT.UScheduler.ScheduleManager\MaksIT.UScheduler.ScheduleManager.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.Shared", "MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.Build.0 = Release|Any CPU - {DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.Build.0 = Release|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {3C81929E-84E5-4648-9FC6-C73902D7E58C} - EndGlobalSection -EndGlobal diff --git a/src/MaksIT.UScheduler.slnx b/src/MaksIT.UScheduler.slnx new file mode 100644 index 0000000..32dbf5d --- /dev/null +++ b/src/MaksIT.UScheduler.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj index a465ae0..c25ba7d 100644 --- a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj +++ b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj @@ -38,7 +38,7 @@ %(Filename)%(Extension) PreserveNewest - + PreserveNewest diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat index 9ccdcaf..20029f8 100644 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat @@ -1,9 +1,3 @@ @echo off - -REM Change directory to the location of the script -cd /d %~dp0 - -REM Run Force Amend Tagged Commit script -powershell -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" - +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" pause diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 index e57c399..d338a36 100644 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS Amends the latest commit, recreates its associated tag, and force pushes both to remote. @@ -16,10 +19,10 @@ If specified, shows what would be done without making changes. .EXAMPLE - .\Force-AmendTaggedCommit.ps1 + pwsh -File .\Force-AmendTaggedCommit.ps1 .EXAMPLE - .\Force-AmendTaggedCommit.ps1 -DryRun + pwsh -File .\Force-AmendTaggedCommit.ps1 -DryRun .NOTES CONFIGURATION (scriptsettings.json): @@ -34,209 +37,213 @@ param( [switch]$DryRun ) -$ErrorActionPreference = "Stop" - -# ============================================================================== -# PATH CONFIGURATION -# ============================================================================== - +# Get the directory of the current script (for loading settings and relative paths) $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$utilsDir = Split-Path $scriptDir -Parent -# ============================================================================== -# LOAD SETTINGS -# ============================================================================== +#region Import Modules -$settingsPath = Join-Path $scriptDir "scriptsettings.json" -if (-not (Test-Path $settingsPath)) { - Write-Error "Settings file not found: $settingsPath" +# Import shared ScriptConfig module (settings loading + dependency checks) +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" exit 1 } -$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json +# Import shared GitTools module (git operations used by this script) +$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} -# Extract settings with defaults +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Helpers + +function Select-PreferredHeadTag { + param( + [Parameter(Mandatory = $true)] + [string[]]$Tags + ) + + # Pick the latest tag on HEAD by git's own ordering (no tag-name parsing assumptions). + $ordered = (& git tag --points-at HEAD --sort=-creatordate 2>$null) + if ($LASTEXITCODE -eq 0 -and $ordered) { + $orderedTags = @($ordered | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + if ($orderedTags.Count -gt 0) { + return $orderedTags[0] + } + } + + # Fallback: keep script functional even if sorting is unavailable. + return $Tags[0] +} + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +# Git configuration with safe defaults when settings are omitted $Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" } $ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true } $ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true } -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== +#endregion -function Write-Step { - param([string]$Text) - Write-Host "`n>> $Text" -ForegroundColor Cyan +#region Validate CLI Dependencies + +Assert-Command git + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Force Amend Tagged Commit Script" +Write-Log -Level "INFO" -Message "========================================" + +if ($DryRun) { + Write-Log -Level "WARN" -Message "*** DRY RUN MODE - No changes will be made ***" } -function Write-Success { - param([string]$Text) - Write-Host " $Text" -ForegroundColor Green +#region Preflight + +# 1. Detect current branch +$Branch = Get-CurrentBranch + +# 2. Read HEAD commit details +Write-LogStep "Getting last commit..." +$CommitMessage = Get-HeadCommitMessage +$CommitHash = Get-HeadCommitHash -Short +Write-Log -Level "INFO" -Message "Commit: $CommitHash - $CommitMessage" + +# 3. Ensure HEAD has at least one tag +Write-LogStep "Finding tag on last commit..." +$tags = Get-HeadTags +if ($tags.Count -eq 0) { + Write-Error "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag." + exit 1 } -function Write-Info { - param([string]$Text) - Write-Host " $Text" -ForegroundColor Gray +# If multiple tags exist, choose the latest one on HEAD by git ordering. +if ($tags.Count -gt 1) { + Write-Log -Level "WARN" -Message "Multiple tags found on HEAD: $($tags -join ', ')" } +$TagName = Select-PreferredHeadTag -Tags $tags +Write-Log -Level "OK" -Message "Found tag: $TagName" -function Write-Warn { - param([string]$Text) - Write-Host " $Text" -ForegroundColor Yellow +# 4. Inspect pending changes before amend +Write-LogStep "Checking pending changes..." +$Status = Get-GitStatusShort +if (-not [string]::IsNullOrWhiteSpace($Status)) { + Write-Log -Level "INFO" -Message "Pending changes:" + $Status -split "`r?`n" | ForEach-Object { Write-Log -Level "INFO" -Message " $_" } } - -function Invoke-Git { - param( - [Parameter(Mandatory = $true)] - [string[]]$Arguments, - - [Parameter(Mandatory = $false)] - [switch]$CaptureOutput, - - [Parameter(Mandatory = $false)] - [string]$ErrorMessage = "Git command failed" - ) - - if ($CaptureOutput) { - $output = & git @Arguments 2>&1 - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { - throw "$ErrorMessage (exit code: $exitCode)" - } - return $output - } else { - & git @Arguments - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { - throw "$ErrorMessage (exit code: $exitCode)" - } - } -} - -# ============================================================================== -# MAIN EXECUTION -# ============================================================================== - -try { - Write-Host "`n========================================" -ForegroundColor Magenta - Write-Host " Force Amend Tagged Commit Script" -ForegroundColor Magenta - Write-Host "========================================`n" -ForegroundColor Magenta - - if ($DryRun) { - Write-Warn "*** DRY RUN MODE - No changes will be made ***`n" - } - - # Get current branch - Write-Step "Getting current branch..." - $Branch = Invoke-Git -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Failed to get current branch" - Write-Info "Branch: $Branch" - - # Get last commit info - Write-Step "Getting last commit..." - $null = Invoke-Git -Arguments @("rev-parse", "HEAD") -CaptureOutput -ErrorMessage "Failed to get HEAD commit" - $CommitMessage = Invoke-Git -Arguments @("log", "-1", "--format=%s") -CaptureOutput - $CommitHash = Invoke-Git -Arguments @("log", "-1", "--format=%h") -CaptureOutput - Write-Info "Commit: $CommitHash - $CommitMessage" - - # Find tag pointing to HEAD - Write-Step "Finding tag on last commit..." - $Tags = & git tag --points-at HEAD 2>&1 - - if (-not $Tags -or [string]::IsNullOrWhiteSpace("$Tags")) { - throw "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag." - } - - # If multiple tags, use the first one - $TagName = ("$Tags" -split "`n")[0].Trim() - Write-Success "Found tag: $TagName" - - # Show current status - Write-Step "Checking pending changes..." - $Status = & git status --short 2>&1 - if ($Status -and -not [string]::IsNullOrWhiteSpace("$Status")) { - Write-Info "Pending changes:" - "$Status" -split "`n" | ForEach-Object { Write-Info " $_" } - } else { - Write-Warn "No pending changes found" - if ($ConfirmWhenNoChanges -and -not $DryRun) { - $confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)" - if ($confirm -ne 'y' -and $confirm -ne 'Y') { - Write-Host "`nAborted by user" -ForegroundColor Yellow - exit 0 - } - } - } - - # Confirm operation - Write-Host "`n----------------------------------------" -ForegroundColor White - Write-Host " Summary of operations:" -ForegroundColor White - Write-Host "----------------------------------------" -ForegroundColor White - Write-Host " Branch: $Branch" -ForegroundColor White - Write-Host " Commit: $CommitHash" -ForegroundColor White - Write-Host " Tag: $TagName" -ForegroundColor White - Write-Host " Remote: $Remote" -ForegroundColor White - Write-Host "----------------------------------------`n" -ForegroundColor White - - if ($ConfirmBeforeAmend -and -not $DryRun) { - $confirm = Read-Host " Proceed with amend and force push? (y/N)" +else { + Write-Log -Level "WARN" -Message "No pending changes found" + if ($ConfirmWhenNoChanges -and -not $DryRun) { + $confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)" if ($confirm -ne 'y' -and $confirm -ne 'Y') { - Write-Host "`nAborted by user" -ForegroundColor Yellow + Write-Log -Level "WARN" -Message "Aborted by user" exit 0 } } - - # Stage all changes - Write-Step "Staging all changes..." - if (-not $DryRun) { - Invoke-Git -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes" - } - Write-Success "All changes staged" - - # Amend commit - Write-Step "Amending commit..." - if (-not $DryRun) { - Invoke-Git -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit" - } - Write-Success "Commit amended" - - # Delete local tag - Write-Step "Deleting local tag '$TagName'..." - if (-not $DryRun) { - Invoke-Git -Arguments @("tag", "-d", $TagName) -ErrorMessage "Failed to delete local tag" - } - Write-Success "Local tag deleted" - - # Recreate tag on new commit - Write-Step "Recreating tag '$TagName' on amended commit..." - if (-not $DryRun) { - Invoke-Git -Arguments @("tag", $TagName) -ErrorMessage "Failed to create tag" - } - Write-Success "Tag recreated" - - # Force push branch - Write-Step "Force pushing branch '$Branch' to $Remote..." - if (-not $DryRun) { - Invoke-Git -Arguments @("push", "--force", $Remote, $Branch) -ErrorMessage "Failed to force push branch" - } - Write-Success "Branch force pushed" - - # Force push tag - Write-Step "Force pushing tag '$TagName' to $Remote..." - if (-not $DryRun) { - Invoke-Git -Arguments @("push", "--force", $Remote, $TagName) -ErrorMessage "Failed to force push tag" - } - Write-Success "Tag force pushed" - - Write-Host "`n========================================" -ForegroundColor Green - Write-Host " Operation completed successfully!" -ForegroundColor Green - Write-Host "========================================`n" -ForegroundColor Green - - # Show final state - Write-Host "Final state:" -ForegroundColor White - & git log -1 --oneline - Write-Host "" - -} catch { - Write-Host "`n========================================" -ForegroundColor Red - Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red - Write-Host "========================================`n" -ForegroundColor Red - exit 1 } + +# 5. Show operation summary and request explicit confirmation +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Summary of operations:" +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Branch: $Branch" +Write-Log -Level "INFO" -Message "Commit: $CommitHash" +Write-Log -Level "INFO" -Message "Tag: $TagName" +Write-Log -Level "INFO" -Message "Remote: $Remote" +Write-Log -Level "INFO" -Message "----------------------------------------" + +if ($ConfirmBeforeAmend -and -not $DryRun) { + $confirm = Read-Host " Proceed with amend and force push? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Log -Level "WARN" -Message "Aborted by user" + exit 0 + } +} + +#endregion + +#region Amend And Push + +# 6. Stage all changes to include them in amended commit +Write-LogStep "Staging all changes..." +if (-not $DryRun) { + Add-AllChanges +} +Write-Log -Level "OK" -Message "All changes staged" + +# 7. Amend HEAD commit while preserving commit message +Write-LogStep "Amending commit..." +if (-not $DryRun) { + Update-HeadCommitNoEdit +} +Write-Log -Level "OK" -Message "Commit amended" + +# 8. Move existing local tag to the amended commit +Write-LogStep "Deleting local tag '$TagName'..." +if (-not $DryRun) { + Remove-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Local tag deleted" + +# 9. Recreate the same tag on new HEAD +Write-LogStep "Recreating tag '$TagName' on amended commit..." +if (-not $DryRun) { + New-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Tag recreated" + +# 10. Force push updated branch history +Write-LogStep "Force pushing branch '$Branch' to $Remote..." +if (-not $DryRun) { + Push-BranchToRemote -Branch $Branch -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Branch force pushed" + +# 11. Force push moved tag +Write-LogStep "Force pushing tag '$TagName' to $Remote..." +if (-not $DryRun) { + Push-TagToRemote -Tag $TagName -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Tag force pushed" + +#endregion + +#region Summary + +Write-Log -Level "OK" -Message "========================================" +Write-Log -Level "OK" -Message "Operation completed successfully!" +Write-Log -Level "OK" -Message "========================================" + +# Show resulting HEAD commit after amend +Write-Log -Level "INFO" -Message "Final state:" +$finalLog = Get-HeadCommitOneLine +Write-Log -Level "INFO" -Message $finalLog + +#endregion + +#endregion diff --git a/utils/Force-AmendTaggedCommit/scriptsettings.json b/utils/Force-AmendTaggedCommit/scriptsettings.json index fad2135..df73911 100644 --- a/utils/Force-AmendTaggedCommit/scriptsettings.json +++ b/utils/Force-AmendTaggedCommit/scriptsettings.json @@ -6,5 +6,13 @@ "remote": "origin", "confirmBeforeAmend": true, "confirmWhenNoChanges": true + }, + + "_comments": { + "git": { + "remote": "Remote name used for force-pushing branch and tag", + "confirmBeforeAmend": "Ask for confirmation before amend + force-push operations", + "confirmWhenNoChanges": "Ask for confirmation when there are no pending changes to amend" + } } } diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat index 46ae950..2790074 100644 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat @@ -1,9 +1,3 @@ @echo off -REM Generate-CoverageBadges.bat - Wrapper for Generate-CoverageBadges.ps1 -REM Runs tests and generates SVG coverage badges for README - -pushd "%~dp0" -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" %* -set EXITCODE=%ERRORLEVEL% -popd -exit /b %EXITCODE% +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" +pause diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 index 21b31e9..24f7d09 100644 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS Generates SVG coverage badges for README. @@ -7,6 +10,7 @@ SVG badges for line, branch, and method coverage. Configuration is stored in scriptsettings.json: + - openReport : Generate and open full HTML report (true/false) - paths.testProject : Relative path to test project - paths.badgesDir : Relative path to badges output directory - badges : Array of badges to generate (name, label, metric) @@ -15,18 +19,12 @@ Badge colors based on coverage: - brightgreen (>=80%), green (>=60%), yellowgreen (>=40%) - yellow (>=20%), orange (>=10%), red (<10%) - -.PARAMETER OpenReport - Generate and open a full HTML coverage report in the default browser. - Requires ReportGenerator: dotnet tool install -g dotnet-reportgenerator-globaltool + If openReport is true, ReportGenerator is required: + dotnet tool install -g dotnet-reportgenerator-globaltool .EXAMPLE - .\Generate-CoverageBadges.ps1 - Runs tests and generates coverage badges. - -.EXAMPLE - .\Generate-CoverageBadges.ps1 -OpenReport - Runs tests, generates badges, and opens HTML report in browser. + pwsh -File .\Generate-CoverageBadges.ps1 + Runs tests and generates coverage badges (and optionally HTML report if configured). .OUTPUTS SVG badge files in the configured badges directory. @@ -35,32 +33,55 @@ Author: MaksIT Requires: .NET SDK, Coverlet (included in test project) #> -param( - [switch]$OpenReport -) $ErrorActionPreference = "Stop" +# Get the directory of the current script (for loading settings and relative paths) $ScriptDir = $PSScriptRoot +$UtilsDir = Split-Path $ScriptDir -Parent -# Import TestRunner module -$ModulePath = Join-Path (Split-Path $ScriptDir -Parent) "TestRunner.psm1" -if (-not (Test-Path $ModulePath)) { - Write-Host "TestRunner module not found at: $ModulePath" -ForegroundColor Red +#region Import Modules + +# Import TestRunner module (executes tests and collects coverage metrics) +$testRunnerModulePath = Join-Path $UtilsDir "TestRunner.psm1" +if (-not (Test-Path $testRunnerModulePath)) { + Write-Error "TestRunner module not found at: $testRunnerModulePath" exit 1 } -Import-Module $ModulePath -Force +Import-Module $testRunnerModulePath -Force -# Load settings -$SettingsFile = Join-Path $ScriptDir "scriptsettings.json" -if (-not (Test-Path $SettingsFile)) { - Write-Host "Settings file not found: $SettingsFile" -ForegroundColor Red +# Import shared ScriptConfig module (settings + command validation helpers) +$scriptConfigModulePath = Join-Path $UtilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" exit 1 } +Import-Module $scriptConfigModulePath -Force -$Settings = Get-Content $SettingsFile | ConvertFrom-Json +# Import shared Logging module (timestamped/aligned output) +$loggingModulePath = Join-Path $UtilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} +Import-Module $loggingModulePath -Force -# Resolve paths from settings +#endregion + +#region Load Settings + +$Settings = Get-ScriptSettings -ScriptDir $ScriptDir + +$thresholds = $Settings.colorThresholds + +#endregion + +#region Configuration + +# Runtime options from settings +$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false } + +# Resolve configured paths to absolute paths $TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject)) $BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir)) @@ -69,29 +90,14 @@ if (-not (Test-Path $BadgesDir)) { New-Item -ItemType Directory -Path $BadgesDir | Out-Null } -# Run tests with coverage -$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport +#endregion -if (-not $coverage.Success) { - Write-Host "Tests failed: $($coverage.Error)" -ForegroundColor Red - exit 1 -} +#region Helpers -Write-Host "Tests passed!" -ForegroundColor Green - -# Store metrics in a hashtable for easy lookup -$metrics = @{ - "line" = $coverage.LineRate - "branch" = $coverage.BranchRate - "method" = $coverage.MethodRate -} - -# Function to get badge color based on coverage percentage and thresholds from settings +# Maps a coverage percentage to a shields.io color using configured thresholds. function Get-BadgeColor { param([double]$percentage) - - $thresholds = $Settings.colorThresholds - + if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" } if ($percentage -ge $thresholds.green) { return "green" } if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" } @@ -100,7 +106,7 @@ function Get-BadgeColor { return "red" } -# Function to create shields.io style SVG badge +# Builds a shields.io-like SVG badge string for one metric. function New-Badge { param( [string]$label, @@ -151,33 +157,59 @@ function New-Badge { "@ } -# Generate badges from settings -Write-Host "Generating coverage badges..." -ForegroundColor Cyan +#endregion + +#region Main + +#region Test And Coverage + +$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport +if (-not $coverage.Success) { + Write-Error "Tests failed: $($coverage.Error)" + exit 1 +} + +Write-Log -Level "OK" -Message "Tests passed!" + +$metrics = @{ + "line" = $coverage.LineRate + "branch" = $coverage.BranchRate + "method" = $coverage.MethodRate +} + +#endregion + +#region Generate Badges + +Write-LogStep -Message "Generating coverage badges..." foreach ($badge in $Settings.badges) { $metricValue = $metrics[$badge.metric] $color = Get-BadgeColor $metricValue $svg = New-Badge -label $badge.label -value "$metricValue%" -color $color $path = Join-Path $BadgesDir $badge.name - $svg | Out-File -FilePath $path -Encoding utf8 - Write-Host " $($badge.name): $($badge.label) = $metricValue%" -ForegroundColor Green + $svg | Out-File -FilePath $path -Encoding utf8NoBOM + Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%" } -# Display summary -Write-Host "" -Write-Host "=== Coverage Summary ===" -ForegroundColor Yellow -Write-Host " Line Coverage: $($coverage.LineRate)%" -Write-Host " Branch Coverage: $($coverage.BranchRate)%" -Write-Host " Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)" -Write-Host "========================" -ForegroundColor Yellow -Write-Host "" -Write-Host "Badges generated in: $BadgesDir" -ForegroundColor Green -Write-Host "Commit the badges/ folder to update README." -ForegroundColor Cyan +#endregion + +#region Summary + +Write-Log -Level "INFO" -Message "Coverage Summary:" +Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%" +Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%" +Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)" +Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir" +Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README." + +#endregion + +#region Optional Html Report -# Optionally generate full HTML report if ($OpenReport -and $coverage.CoverageFile) { - Write-Host "" - Write-Host "Generating HTML report..." -ForegroundColor Cyan + Write-LogStep -Message "Generating HTML report..." + Assert-Command reportgenerator $ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent $ReportDir = Join-Path $ResultsDir "report" @@ -194,5 +226,9 @@ if ($OpenReport -and $coverage.CoverageFile) { Start-Process $IndexFile } - Write-Host "TestResults kept for HTML report viewing." -ForegroundColor Gray + Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing." } + +#endregion + +#endregion diff --git a/utils/Generate-CoverageBadges/scriptsettings.json b/utils/Generate-CoverageBadges/scriptsettings.json index 6e883cc..2000db7 100644 --- a/utils/Generate-CoverageBadges/scriptsettings.json +++ b/utils/Generate-CoverageBadges/scriptsettings.json @@ -2,9 +2,10 @@ "$schema": "https://json-schema.org/draft-07/schema", "title": "Generate Coverage Badges Script Settings", "description": "Configuration for Generate-CoverageBadges.ps1 script", + "openReport": false, "paths": { "testProject": "..\\..\\src\\MaksIT.UScheduler.Tests", - "badgesDir": "..\\..\\badges" + "badgesDir": "..\\..\\assets\\badges" }, "badges": [ { @@ -30,5 +31,14 @@ "yellow": 20, "orange": 10, "red": 0 + }, + "_comments": { + "openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).", + "paths": { + "testProject": "Relative path to test project used by TestRunner.", + "badgesDir": "Relative path where SVG coverage badges are written." + }, + "badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.", + "colorThresholds": "Coverage percentage thresholds used to pick badge colors." } } diff --git a/utils/GitTools.psm1 b/utils/GitTools.psm1 new file mode 100644 index 0000000..405f408 --- /dev/null +++ b/utils/GitTools.psm1 @@ -0,0 +1,268 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +# +# Shared Git helpers for utility scripts. +# + +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-GitToolsLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + +# Internal: +# Purpose: +# - Execute a git command and enforce fail-fast error handling. +function Invoke-GitInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $false)] + [switch]$CaptureOutput, + + [Parameter(Mandatory = $false)] + [string]$ErrorMessage = "Git command failed" + ) + + if ($CaptureOutput) { + $output = & git @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } + + if ($null -eq $output) { + return "" + } + + return ($output -join "`n").Trim() + } + + & git @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Resolve and print the current branch name. +function Get-CurrentBranch { + Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..." + + $branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch" + Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch" + return $branch +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Return `git status --short` output for pending-change checks. +function Get-GitStatusShort { + return Invoke-GitInternal -Arguments @("status", "--short") -CaptureOutput -ErrorMessage "Failed to get git status" +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Get exact tag name attached to HEAD (release flow). +function Get-CurrentCommitTag { + param( + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-GitToolsLogInternal -Level "STEP" -Message "Checking for tag on current commit..." + $tag = Invoke-GitInternal -Arguments @("describe", "--tags", "--exact-match", "HEAD") -CaptureOutput -ErrorMessage "No tag found on current commit. Create a tag: git tag v$Version" + return $tag +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get all tag names pointing at HEAD. +function Get-HeadTags { + $tagsRaw = Invoke-GitInternal -Arguments @("tag", "--points-at", "HEAD") -CaptureOutput -ErrorMessage "Failed to list tags on HEAD" + + if ([string]::IsNullOrWhiteSpace($tagsRaw)) { + return @() + } + + return @($tagsRaw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Check whether a given tag exists on the remote. +function Test-RemoteTagExists { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin" + ) + + $remoteTag = Invoke-GitInternal -Arguments @("ls-remote", "--tags", $Remote, $Tag) -CaptureOutput -ErrorMessage "Failed to check remote tag existence" + return [bool]$remoteTag +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push tag to remote (optionally with `--force`). +function Push-TagToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Tag) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push tag $Tag to remote $Remote" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push branch to remote (optionally with `--force`). +function Push-BranchToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Branch, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Branch) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push branch $Branch to remote $Remote" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit hash. +function Get-HeadCommitHash { + param( + [Parameter(Mandatory = $false)] + [switch]$Short + ) + + $format = if ($Short) { "--format=%h" } else { "--format=%H" } + return Invoke-GitInternal -Arguments @("log", "-1", $format) -CaptureOutput -ErrorMessage "Failed to get HEAD commit hash" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit subject line. +function Get-HeadCommitMessage { + return Invoke-GitInternal -Arguments @("log", "-1", "--format=%s") -CaptureOutput -ErrorMessage "Failed to get HEAD commit message" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Stage all changes (tracked, untracked, deletions). +function Add-AllChanges { + Invoke-GitInternal -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Amend HEAD commit and keep existing commit message. +function Update-HeadCommitNoEdit { + Invoke-GitInternal -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Delete local tag. +function Remove-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", "-d", $Tag) -ErrorMessage "Failed to delete local tag" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Create local tag. +function New-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", $Tag) -ErrorMessage "Failed to create tag" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD one-line commit info. +function Get-HeadCommitOneLine { + return Invoke-GitInternal -Arguments @("log", "-1", "--oneline") -CaptureOutput -ErrorMessage "Failed to read final commit state" +} + +Export-ModuleMember -Function Get-CurrentBranch, Get-GitStatusShort, Get-CurrentCommitTag, Get-HeadTags, Test-RemoteTagExists, Push-TagToRemote, Push-BranchToRemote, Get-HeadCommitHash, Get-HeadCommitMessage, Add-AllChanges, Update-HeadCommitNoEdit, Remove-LocalTag, New-LocalTag, Get-HeadCommitOneLine diff --git a/utils/Logging.psm1 b/utils/Logging.psm1 new file mode 100644 index 0000000..a0cbb3d --- /dev/null +++ b/utils/Logging.psm1 @@ -0,0 +1,70 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-LogTimestampInternal { + return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") +} + +function Get-LogColorInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Level + ) + + switch ($Level.ToUpperInvariant()) { + "OK" { return "Green" } + "INFO" { return "Gray" } + "WARN" { return "Yellow" } + "ERROR" { return "Red" } + "STEP" { return "Cyan" } + "DEBUG" { return "DarkGray" } + default { return "White" } + } +} + +function Write-Log { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO", + + [Parameter(Mandatory = $false)] + [switch]$NoTimestamp + ) + + $levelToken = "[$($Level.ToUpperInvariant())]" + $padding = " " * [Math]::Max(1, (10 - $levelToken.Length)) + $prefix = if ($NoTimestamp) { "" } else { "[$(Get-LogTimestampInternal)] " } + $line = "$prefix$levelToken$padding$Message" + + Write-Host $line -ForegroundColor (Get-LogColorInternal -Level $Level) +} + +function Write-LogStep { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Log -Level "STEP" -Message $Message +} + +function Write-LogStepResult { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("OK", "FAIL")] + [string]$Status, + + [Parameter(Mandatory = $false)] + [string]$Message + ) + + $level = if ($Status -eq "FAIL") { "ERROR" } else { "OK" } + $text = if ([string]::IsNullOrWhiteSpace($Message)) { $Status } else { $Message } + Write-Log -Level $level -Message $text +} + +Export-ModuleMember -Function Write-Log, Write-LogStep, Write-LogStepResult diff --git a/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 b/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 new file mode 100644 index 0000000..43dc044 --- /dev/null +++ b/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 @@ -0,0 +1,121 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Cleanup plugin for removing generated artifacts after pipeline completion. + +.DESCRIPTION + This plugin removes files from the configured artifacts directory using + glob patterns. It is typically placed at the end of the Release stage so + cleanup becomes explicit and opt-in per repository. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-CleanupPatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @('*.nupkg', '*.snupkg') + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @('*.nupkg', '*.snupkg') + } + + return @([string]$ConfiguredPatterns) +} + +function Get-ExcludePatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @() + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @() + } + + return @([string]$ConfiguredPatterns) +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns + $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CleanupArtifacts plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" + return + } + + Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." + + $itemsToRemove = @() + foreach ($pattern in $patterns) { + $matchedItems = @( + Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } + ) + + if ($excludePatterns.Count -gt 0) { + $matchedItems = @( + $matchedItems | + Where-Object { + $item = $_ + -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) + } + ) + } + + $itemsToRemove += @($matchedItems) + } + + $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) + + if ($itemsToRemove.Count -eq 0) { + Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." + return + } + + foreach ($item in $itemsToRemove) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Removed: $($item.Name)" + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/CreateArchive.psm1 b/utils/Release-Package/CorePlugins/CreateArchive.psm1 new file mode 100644 index 0000000..54cce44 --- /dev/null +++ b/utils/Release-Package/CorePlugins/CreateArchive.psm1 @@ -0,0 +1,93 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Creates a release zip from prepared build artifacts. + +.DESCRIPTION + This plugin compresses the release artifact inputs prepared by an earlier + producer plugin (for example DotNetPack or DotNetPublish) into a zip file + and exposes the resulting release assets for later publisher plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $version = $sharedSettings.Version + $archiveInputs = @() + + if ($sharedSettings.PSObject.Properties['ReleaseArchiveInputs'] -and $sharedSettings.ReleaseArchiveInputs) { + $archiveInputs = @($sharedSettings.ReleaseArchiveInputs) + } + elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $archiveInputs = @($sharedSettings.PackageFile.FullName) + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $archiveInputs += $sharedSettings.SymbolsPackageFile.FullName + } + } + + if ($archiveInputs.Count -eq 0) { + throw "CreateArchive plugin requires prepared artifacts. Run a producer plugin (for example DotNetPack or DotNetPublish) first." + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CreateArchive plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $zipNamePattern = if ($pluginSettings.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.zipNamePattern)) { + [string]$pluginSettings.zipNamePattern + } + else { + "release-{version}.zip" + } + + $zipFileName = $zipNamePattern -replace '\{version\}', $version + $zipPath = Join-Path $artifactsDirectory $zipFileName + + if (Test-Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + + Write-Log -Level "STEP" -Message "Creating release archive..." + Compress-Archive -Path $archiveInputs -DestinationPath $zipPath -CompressionLevel Optimal -Force + + if (-not (Test-Path $zipPath -PathType Leaf)) { + throw "Failed to create release archive at: $zipPath" + } + + Write-Log -Level "OK" -Message " Release archive ready: $zipPath" + + $releaseAssetPaths = @($zipPath) + if ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $releaseAssetPaths += $sharedSettings.PackageFile.FullName + } + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName + } + + $sharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $artifactsDirectory -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchivePath -NotePropertyValue $zipPath -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetPack.psm1 b/utils/Release-Package/CorePlugins/DotNetPack.psm1 new file mode 100644 index 0000000..8353217 --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetPack.psm1 @@ -0,0 +1,99 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET pack plugin for producing package artifacts. + +.DESCRIPTION + This plugin creates package output for the release pipeline. + It packs the configured .NET project, resolves the generated + package artifacts, and publishes them into shared runtime context + for later plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + # Load this globally only as a fallback. Re-importing PluginSupport in its own execution path + # can invalidate commands already resolved by the release engine. + 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" + + $sharedSettings = $Settings.Context + $projectFiles = $sharedSettings.ProjectFiles + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $version = $sharedSettings.Version + $packageProjectPath = $null + $releaseArchiveInputs = @() + + Assert-Command dotnet + + if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) { + throw "DotNetPack plugin requires project files in the shared context." + } + + $outputDir = $artifactsDirectory + + if (!(Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir | Out-Null + } + + # The release context guarantees ProjectFiles is an array, so index 0 is the first project path, + # not the first character of a string. + $packageProjectPath = $projectFiles[0] + Write-Log -Level "STEP" -Message "Packing NuGet package..." + dotnet pack $packageProjectPath -c Release -o $outputDir --nologo ` + -p:IncludeSymbols=true ` + -p:SymbolPackageFormat=snupkg + if ($LASTEXITCODE -ne 0) { + throw "dotnet pack failed for $packageProjectPath." + } + + # dotnet pack can leave older packages in the artifacts directory. + # Pick the newest file matching the current version rather than assuming a clean folder. + $packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" | + Where-Object { + $_.Name -like "*$version*.nupkg" -and + $_.Name -notlike "*.symbols.nupkg" -and + $_.Name -notlike "*.snupkg" + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $packageFile) { + throw "Could not locate generated NuGet package for version $version in: $outputDir" + } + + Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = Get-ChildItem -Path $outputDir -Filter "*.snupkg" | + Where-Object { $_.Name -like "*$version*.snupkg" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($symbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)" + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + else { + Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." + } + + $sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $packageFile -Force + $sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetPublish.psm1 b/utils/Release-Package/CorePlugins/DotNetPublish.psm1 new file mode 100644 index 0000000..8acb8bc --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetPublish.psm1 @@ -0,0 +1,71 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET publish plugin for producing application release artifacts. + +.DESCRIPTION + This plugin publishes the configured .NET project into a release output + directory and exposes that published directory to the shared release + context so later release-stage plugins can archive and publish it. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $sharedSettings = $Settings.Context + $projectFiles = $sharedSettings.ProjectFiles + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $publishProjectPath = $null + + Assert-Command dotnet + + if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) { + throw "DotNetPublish plugin requires project files in the shared context." + } + + if (!(Test-Path $artifactsDirectory)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + # The first configured project remains the canonical release artifact source. + $publishProjectPath = $projectFiles[0] + $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)) + + if (Test-Path $publishDir) { + Remove-Item -Path $publishDir -Recurse -Force + } + + Write-Log -Level "STEP" -Message "Publishing release artifact..." + dotnet publish $publishProjectPath -c Release -o $publishDir --nologo + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for $publishProjectPath." + } + + $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue) + if ($publishedItems.Count -eq 0) { + throw "dotnet publish completed, but no files were produced in: $publishDir" + } + + Write-Log -Level "OK" -Message " Published artifact ready: $publishDir" + + $sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue @($publishDir) -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetTest.psm1 b/utils/Release-Package/CorePlugins/DotNetTest.psm1 new file mode 100644 index 0000000..7759fc0 --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetTest.psm1 @@ -0,0 +1,72 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET test plugin for executing automated tests. + +.DESCRIPTION + This plugin resolves the configured .NET test project and optional + results directory, runs tests through TestRunner, and stores + the resulting test metrics in shared runtime context. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + # Same fallback pattern as the other plugins: use the existing shared module if it is already loaded. + 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-TestsWithCoverage" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $testProjectSetting = $pluginSettings.project + $testResultsDirSetting = $pluginSettings.resultsDir + $scriptDir = $sharedSettings.ScriptDir + + if ([string]::IsNullOrWhiteSpace($testProjectSetting)) { + throw "DotNetTest plugin requires 'project' in scriptsettings.json." + } + + $testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testProjectSetting)) + $testResultsDir = $null + if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) { + $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting)) + } + + Write-Log -Level "STEP" -Message "Running tests..." + + # Build a splatted hashtable so optional arguments can be added without duplicating the call site. + $invokeTestParams = @{ + TestProjectPath = $testProjectPath + Silent = $true + } + if ($testResultsDir) { + $invokeTestParams.ResultsDirectory = $testResultsDir + } + + $testResult = Invoke-TestsWithCoverage @invokeTestParams + + if (-not $testResult.Success) { + throw "Tests failed. $($testResult.Error)" + } + + $sharedSettings | Add-Member -NotePropertyName TestResult -NotePropertyValue $testResult -Force + + 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 diff --git a/utils/Release-Package/CorePlugins/GitHub.psm1 b/utils/Release-Package/CorePlugins/GitHub.psm1 new file mode 100644 index 0000000..38a9386 --- /dev/null +++ b/utils/Release-Package/CorePlugins/GitHub.psm1 @@ -0,0 +1,232 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + GitHub release plugin. + +.DESCRIPTION + This plugin validates GitHub CLI access, resolves the target + repository, and creates the configured GitHub release using the + shared release artifacts and extracted release notes. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-GitHubRepositoryInternal { + param( + [Parameter(Mandatory = $false)] + [string]$ConfiguredRepository + ) + + $repoSource = $ConfiguredRepository + + if ([string]::IsNullOrWhiteSpace($repoSource)) { + $repoSource = git config --get remote.origin.url + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoSource)) { + throw "Could not determine git remote origin URL." + } + } + + $repoSource = $repoSource.Trim() + + if ($repoSource -match "(?i)github\.com[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + if ($repoSource -match "^(?[^/]+)/(?[^/]+)$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + throw "Could not parse GitHub repo from source: $repoSource. Configure Plugins[].repository with 'owner/repo' or a GitHub URL." +} + +function Get-ReleaseNotesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesFile, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-Log -Level "INFO" -Message "Verifying release notes source..." + if (-not (Test-Path $ReleaseNotesFile -PathType Leaf)) { + throw "Release notes source file not found at: $ReleaseNotesFile" + } + + $releaseNotesContent = Get-Content $ReleaseNotesFile -Raw + if ($releaseNotesContent -notmatch '##\s+v(\d+\.\d+\.\d+)') { + throw "No version entry found in the configured release notes source." + } + + $releaseNotesVersion = $Matches[1] + if ($releaseNotesVersion -ne $Version) { + throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)." + } + + Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion" + + Write-Log -Level "STEP" -Message "Extracting release notes..." + $pattern = "(?ms)^##\s+v$([regex]::Escape($Version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)" + $match = [regex]::Match($releaseNotesContent, $pattern) + + if (-not $match.Success) { + throw "Release notes entry for version $Version not found." + } + + Write-Log -Level "OK" -Message " Release notes extracted." + return $match.Value.Trim() +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $githubTokenEnvVar = $pluginSettings.githubToken + $configuredRepository = $pluginSettings.repository + $releaseNotesFileSetting = $pluginSettings.releaseNotesFile + $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern + $scriptDir = $sharedSettings.ScriptDir + $version = $sharedSettings.Version + $tag = $sharedSettings.Tag + $releaseDir = $sharedSettings.ReleaseDir + $releaseAssetPaths = @() + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { + throw "GitHub plugin requires 'githubToken' in scriptsettings.json." + } + + $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) + if ([string]::IsNullOrWhiteSpace($githubToken)) { + throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + } + + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptsettings.json." + } + + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($sharedSettings.PSObject.Properties['ReleaseAssetPaths'] -and $sharedSettings.ReleaseAssetPaths) { + $releaseAssetPaths = @($sharedSettings.ReleaseAssetPaths) + } + elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $releaseAssetPaths = @($sharedSettings.PackageFile.FullName) + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName + } + } + + if ($releaseAssetPaths.Count -eq 0) { + throw "GitHub release requires at least one prepared release asset." + } + + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + + Write-Log -Level "INFO" -Message " GitHub repository: $repo" + Write-Log -Level "INFO" -Message " GitHub tag: $tag" + Write-Log -Level "INFO" -Message " GitHub title: $releaseName" + + $previousGhToken = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + + try { + $ghVersion = & gh --version 2>&1 + if ($ghVersion) { + Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" + } + + Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + + $authArgs = @("api", "repos/$repo", "--jq", ".full_name") + $authOutput = & gh @authArgs 2>&1 + $authExitCode = $LASTEXITCODE + + if ($authExitCode -ne 0 -or [string]::IsNullOrWhiteSpace(($authOutput | Out-String))) { + Write-Log -Level "WARN" -Message " gh auth check failed (exit code: $authExitCode)." + if ($authOutput) { + $authOutput | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + } + + $authStatus = & gh auth status --hostname github.com 2>&1 + if ($authStatus) { + $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + } + + throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + } + + Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" + Write-Log -Level "STEP" -Message "Creating GitHub release..." + + $releaseViewArgs = @("release", "view", $tag, "--repo", $repo) + & gh @releaseViewArgs 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." + $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") + & gh @releaseDeleteArgs + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete existing release $tag." + } + } + + $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) + + try { + [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) + + $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( + "--repo", $repo, + "--title", $releaseName, + "--notes-file", $notesFilePath + ) + & gh @createReleaseArgs + + if ($LASTEXITCODE -ne 0) { + throw "Failed to create GitHub release for tag $tag." + } + } + finally { + if (Test-Path $notesFilePath) { + Remove-Item $notesFilePath -Force + } + } + + Write-Log -Level "OK" -Message " GitHub release created successfully." + $sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force + } + finally { + if ($null -ne $previousGhToken) { + $env:GH_TOKEN = $previousGhToken + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/NuGet.psm1 b/utils/Release-Package/CorePlugins/NuGet.psm1 new file mode 100644 index 0000000..4dafc54 --- /dev/null +++ b/utils/Release-Package/CorePlugins/NuGet.psm1 @@ -0,0 +1,67 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + NuGet publish plugin. + +.DESCRIPTION + This plugin publishes the package artifact from shared runtime + context to the configured NuGet feed using the configured API key. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $packageFile = $sharedSettings.PackageFile + + Assert-Command dotnet + + if (-not $packageFile) { + throw "NuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running NuGet." + } + + if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { + throw "NuGet plugin requires 'nugetApiKey' in scriptsettings.json." + } + + $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) + if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { + throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + } + + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "STEP" -Message "Pushing to NuGet.org..." + dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + + if ($LASTEXITCODE -ne 0) { + throw "Failed to push the package to NuGet." + } + + Write-Log -Level "OK" -Message " NuGet push completed." + $sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/QualityGate.psm1 b/utils/Release-Package/CorePlugins/QualityGate.psm1 new file mode 100644 index 0000000..450a468 --- /dev/null +++ b/utils/Release-Package/CorePlugins/QualityGate.psm1 @@ -0,0 +1,119 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Quality gate plugin for validating release readiness. + +.DESCRIPTION + This plugin evaluates quality constraints using shared test + results and project files. It enforces coverage thresholds + and checks for vulnerable packages before release plugins run. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Test-VulnerablePackagesInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + $findings = @() + + foreach ($projectPath in $ProjectFiles) { + Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))" + + $output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet list package --vulnerable failed for $projectPath." + } + + $outputText = ($output | Out-String) + if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") { + $findings += [pscustomobject]@{ + Project = $projectPath + Output = $outputText.Trim() + } + } + } + + return $findings +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $coverageThresholdSetting = $pluginSettings.coverageThreshold + $failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities + $projectFiles = $sharedSettings.ProjectFiles + $testResult = $null + if ($sharedSettings.PSObject.Properties['TestResult']) { + $testResult = $sharedSettings.TestResult + } + + if ($null -eq $testResult) { + throw "QualityGate plugin requires test results. Run the DotNetTest plugin first." + } + + $coverageThreshold = 0 + if ($null -ne $coverageThresholdSetting) { + $coverageThreshold = [double]$coverageThresholdSetting + } + + if ($coverageThreshold -gt 0) { + Write-Log -Level "STEP" -Message "Checking coverage threshold..." + if ([double]$testResult.LineRate -lt $coverageThreshold) { + throw "Line coverage $($testResult.LineRate)% is below the configured threshold of $coverageThreshold%." + } + + Write-Log -Level "OK" -Message " Coverage threshold met: $($testResult.LineRate)% >= $coverageThreshold%" + } + else { + Write-Log -Level "WARN" -Message "Skipping coverage threshold check (disabled)." + } + + Assert-Command dotnet + + $failOnVulnerabilities = $true + if ($null -ne $failOnVulnerabilitiesSetting) { + $failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting + } + + $vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles + + if ($vulnerabilities.Count -eq 0) { + Write-Log -Level "OK" -Message " No vulnerable packages detected." + return + } + + foreach ($finding in $vulnerabilities) { + Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))" + $finding.Output -split "`r?`n" | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_)) { + Write-Log -Level "WARN" -Message " $_" + } + } + } + + if ($failOnVulnerabilities) { + throw "Vulnerable packages were detected and failOnVulnerabilities is enabled." + } + + Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CustomPlugins/.gitkeep b/utils/Release-Package/CustomPlugins/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/Release-Package/CustomPlugins/.gitkeep @@ -0,0 +1 @@ + diff --git a/utils/Release-Package/CustomPlugins/BundleCustomization.psm1 b/utils/Release-Package/CustomPlugins/BundleCustomization.psm1 new file mode 100644 index 0000000..e05906b --- /dev/null +++ b/utils/Release-Package/CustomPlugins/BundleCustomization.psm1 @@ -0,0 +1,314 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Customizes the staged release bundle after build artifacts are produced. + +.DESCRIPTION + Recreates the legacy release bundle layout by: + - publishing all configured .NET projects into bundle/bin/ + - copying the Scripts folder into bundle/Scripts + - rewriting appsettings.json for the bundled runtime layout + - optionally creating a launcher batch file + + The plugin then updates the shared release context so CreateArchive zips the + fully prepared bundle directory instead of the raw publish output. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Resolve-PluginPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path)) +} + +function Get-ProjectPropertyValueInternal { + param( + [Parameter(Mandatory = $true)] + [xml]$Csproj, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + $propNode = $Csproj.Project.PropertyGroup | + Where-Object { $_.$PropertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$PropertyName + } + + return $null +} + +function Resolve-ProjectExeNameInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectPath + ) + + [xml]$csproj = Get-Content $ProjectPath + $assemblyName = Get-ProjectPropertyValueInternal -Csproj $csproj -PropertyName "AssemblyName" + + if (-not [string]::IsNullOrWhiteSpace([string]$assemblyName)) { + return [string]$assemblyName + } + + return [System.IO.Path]::GetFileNameWithoutExtension($ProjectPath) +} + +function Find-ProjectBySuffixInternal { + param( + [Parameter(Mandatory = $true)] + [object[]]$PublishedProjects, + + [Parameter(Mandatory = $false)] + [string]$Suffix + ) + + if ([string]::IsNullOrWhiteSpace($Suffix)) { + return $null + } + + return $PublishedProjects | + Where-Object { $_.ProjectPath -like "*$Suffix" } | + Select-Object -First 1 +} + +function Set-JsonFileContentInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $jsonOutput = $Value | ConvertTo-Json -Depth 20 + Set-Content -Path $Path -Value $jsonOutput -Encoding UTF8 +} + +function Ensure-NotePropertyInternal { + param( + [Parameter(Mandatory = $true)] + [object]$Target, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [object]$PropertyValue + ) + + if ($Target.PSObject.Properties[$PropertyName]) { + $Target.$PropertyName = $PropertyValue + return + } + + $Target | Add-Member -MemberType NoteProperty -Name $PropertyName -Value $PropertyValue +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + + if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $sharedSettings.ProjectFiles.Count -eq 0) { + throw "BundleCustomization plugin requires project files in the shared context." + } + + $scriptDir = $sharedSettings.ScriptDir + $projectFiles = @($sharedSettings.ProjectFiles) + $bundleDirectory = if ($pluginSettings.PSObject.Properties['bundleDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.bundleDir)) { + Resolve-PluginPath -Path ([string]$pluginSettings.bundleDir) -BasePath $scriptDir + } + else { + Join-Path $sharedSettings.ArtifactsDirectory "bundle" + } + + if (-not $pluginSettings.PSObject.Properties['scriptsPath'] -or [string]::IsNullOrWhiteSpace([string]$pluginSettings.scriptsPath)) { + throw "BundleCustomization plugin requires a scriptsPath setting." + } + + $scriptsSourcePath = Resolve-PluginPath -Path ([string]$pluginSettings.scriptsPath) -BasePath $scriptDir + if (-not (Test-Path $scriptsSourcePath -PathType Container)) { + throw "Scripts folder not found: $scriptsSourcePath" + } + + Assert-Command dotnet + + Write-Log -Level "STEP" -Message "Preparing customized release bundle..." + + if (Test-Path $bundleDirectory) { + Remove-Item -Path $bundleDirectory -Recurse -Force + } + + New-Item -ItemType Directory -Path $bundleDirectory | Out-Null + + $binDirectory = Join-Path $bundleDirectory "bin" + New-Item -ItemType Directory -Path $binDirectory | Out-Null + + $publishedProjects = @() + + foreach ($projectPath in $projectFiles) { + if (-not (Test-Path $projectPath -PathType Leaf)) { + throw "Project file not found: $projectPath" + } + + $projectName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath) + $projectBinDirectory = Join-Path $binDirectory $projectName + + Write-Log -Level "STEP" -Message " Publishing $projectName into bundle..." + dotnet publish $projectPath -c Release -o $projectBinDirectory --nologo + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for $projectPath." + } + + $exeBaseName = Resolve-ProjectExeNameInternal -ProjectPath $projectPath + $publishedProjects += [pscustomobject]@{ + ProjectPath = $projectPath + ProjectName = $projectName + BinDirectory = $projectBinDirectory + ExeBaseName = $exeBaseName + } + + Write-Log -Level "OK" -Message " Published: $projectBinDirectory" + } + + $scriptsDestination = Join-Path $bundleDirectory "Scripts" + Copy-Item -Path $scriptsSourcePath -Destination $scriptsDestination -Recurse + Write-Log -Level "OK" -Message " Scripts copied: $scriptsDestination" + + if ($pluginSettings.PSObject.Properties['projects'] -and $null -ne $pluginSettings.projects) { + $projectConfig = $pluginSettings.projects + + $scheduleManagerProject = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$projectConfig.scheduleManagerCsprojEndsWith) + if ($null -ne $scheduleManagerProject) { + $scheduleManagerAppSettingsFile = [string]$projectConfig.scheduleManagerAppSettingsFile + $scheduleManagerAppSettingsPath = Join-Path $scheduleManagerProject.BinDirectory $scheduleManagerAppSettingsFile + + if (Test-Path $scheduleManagerAppSettingsPath -PathType Leaf) { + $scheduleManagerAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json + if ($scheduleManagerAppSettings.PSObject.Properties['USchedulerSettings'] -and $null -ne $scheduleManagerAppSettings.USchedulerSettings) { + Ensure-NotePropertyInternal -Target $scheduleManagerAppSettings.USchedulerSettings -PropertyName "ServiceBinPath" -PropertyValue ([string]$projectConfig.scheduleManagerServiceBinPath) + Set-JsonFileContentInternal -Path $scheduleManagerAppSettingsPath -Value $scheduleManagerAppSettings + Write-Log -Level "OK" -Message " Updated ScheduleManager appsettings." + } + else { + Write-Log -Level "WARN" -Message " ScheduleManager appsettings has no USchedulerSettings section." + } + } + else { + Write-Log -Level "WARN" -Message " ScheduleManager appsettings not found: $scheduleManagerAppSettingsPath" + } + } + else { + Write-Log -Level "WARN" -Message " ScheduleManager project not found in configured project files." + } + + $uSchedulerProject = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$projectConfig.uschedulerCsprojEndsWith) + if ($null -ne $uSchedulerProject) { + $uSchedulerAppSettingsFile = [string]$projectConfig.uschedulerAppSettingsFile + $uSchedulerAppSettingsPath = Join-Path $uSchedulerProject.BinDirectory $uSchedulerAppSettingsFile + + if (Test-Path $uSchedulerAppSettingsPath -PathType Leaf) { + $uSchedulerAppSettings = Get-Content $uSchedulerAppSettingsPath -Raw | ConvertFrom-Json + + if (-not $uSchedulerAppSettings.PSObject.Properties['Configuration'] -or $null -eq $uSchedulerAppSettings.Configuration) { + Ensure-NotePropertyInternal -Target $uSchedulerAppSettings -PropertyName "Configuration" -PropertyValue ([pscustomobject]@{}) + } + + Ensure-NotePropertyInternal -Target $uSchedulerAppSettings.Configuration -PropertyName "LogDir" -PropertyValue ([string]$projectConfig.uschedulerLogDir) + + $powerShellScripts = @( + Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse -File | + Where-Object { $_.Directory.Name -ne "Utilities" } | + ForEach-Object { + $relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\') + $scriptPath = "{0}\{1}" -f ([string]$projectConfig.scriptsRelativeToExe), $relativePath + + [pscustomobject]@{ + Path = $scriptPath + IsSigned = $false + Disabled = $true + } + } + ) + + Ensure-NotePropertyInternal -Target $uSchedulerAppSettings.Configuration -PropertyName "Powershell" -PropertyValue $powerShellScripts + Set-JsonFileContentInternal -Path $uSchedulerAppSettingsPath -Value $uSchedulerAppSettings + + Write-Log -Level "OK" -Message " Updated UScheduler appsettings." + if ($powerShellScripts.Count -gt 0) { + Write-Log -Level "INFO" -Message " Added $($powerShellScripts.Count) bundled script entries." + } + } + else { + Write-Log -Level "WARN" -Message " UScheduler appsettings not found: $uSchedulerAppSettingsPath" + } + } + else { + Write-Log -Level "WARN" -Message " UScheduler project not found in configured project files." + } + } + + if ($pluginSettings.PSObject.Properties['launcher'] -and $null -ne $pluginSettings.launcher -and $pluginSettings.launcher.enabled) { + $launcherSettings = $pluginSettings.launcher + $launcherTarget = $null + + switch ([string]$launcherSettings.targetProject) { + "scheduleManager" { + $launcherTarget = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$pluginSettings.projects.scheduleManagerCsprojEndsWith) + } + "uscheduler" { + $launcherTarget = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$pluginSettings.projects.uschedulerCsprojEndsWith) + } + default { + Write-Log -Level "WARN" -Message " Unknown launcher targetProject '$([string]$launcherSettings.targetProject)'." + } + } + + if ($null -ne $launcherTarget) { + $launcherPath = Join-Path $bundleDirectory ([string]$launcherSettings.fileName) + $launcherExePath = "%~dp0bin\$($launcherTarget.ProjectName)\$($launcherTarget.ExeBaseName).exe" + $launcherContent = @" +@echo off +start "" "$launcherExePath" +"@ + + Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII + Write-Log -Level "OK" -Message " Created launcher: $launcherPath" + } + else { + Write-Log -Level "WARN" -Message " Launcher target project could not be resolved." + } + } + + $sharedSettings | Add-Member -NotePropertyName BundleDirectory -NotePropertyValue $bundleDirectory -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue @($bundleDirectory) -Force + + Write-Log -Level "OK" -Message "Customized release bundle ready: $bundleDirectory" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/DotNetProjectSupport.psm1 b/utils/Release-Package/DotNetProjectSupport.psm1 new file mode 100644 index 0000000..a510eb5 --- /dev/null +++ b/utils/Release-Package/DotNetProjectSupport.psm1 @@ -0,0 +1,110 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-PluginPathListSetting -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +function Get-DotNetProjectPropertyValue { + param( + [Parameter(Mandatory = $true)] + [xml]$Csproj, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + # SDK-style .csproj files can have multiple PropertyGroup nodes. + # Use the first group that defines the requested property. + $propNode = $Csproj.Project.PropertyGroup | + Where-Object { $_.$PropertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$PropertyName + } + + return $null +} + +function Get-DotNetProjectVersions { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + Write-Log -Level "INFO" -Message "Reading version(s) from .NET project files..." + $projectVersions = @{} + + foreach ($projectPath in $ProjectFiles) { + if (-not (Test-Path $projectPath -PathType Leaf)) { + Write-Error "Project file not found at: $projectPath" + exit 1 + } + + if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") { + Write-Error "Configured project file is not a .csproj file: $projectPath" + exit 1 + } + + [xml]$csproj = Get-Content $projectPath + $version = Get-DotNetProjectPropertyValue -Csproj $csproj -PropertyName "Version" + + if (-not $version) { + Write-Error "Version not found in $projectPath" + exit 1 + } + + $projectVersions[$projectPath] = $version + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version" + } + + return $projectVersions +} + +function New-DotNetReleaseContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + # The array wrapper is intentional: without it, one configured project can collapse to a string, + # and later indexing [0] would return only the first character of the path. + $projectFiles = @(Get-PluginPathListSetting -Plugins $Plugins -PropertyName "projectFiles" -BasePath $ScriptDir) + $artifactsDirectory = Get-PluginPathSetting -Plugins $Plugins -PropertyName "artifactsDir" -BasePath $ScriptDir + + if ($projectFiles.Count -eq 0) { + Write-Error "No .NET project files configured in plugin settings. Add 'projectFiles' to a relevant plugin." + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + Write-Error "No artifacts directory configured in plugin settings. Add 'artifactsDir' to a relevant plugin." + exit 1 + } + + $projectVersions = Get-DotNetProjectVersions -ProjectFiles $projectFiles + # The first configured project is treated as the canonical version source for the release. + $version = $projectVersions[$projectFiles[0]] + + return [pscustomobject]@{ + ProjectFiles = $projectFiles + ArtifactsDirectory = $artifactsDirectory + Version = $version + } +} + +Export-ModuleMember -Function Get-DotNetProjectPropertyValue, Get-DotNetProjectVersions, New-DotNetReleaseContext diff --git a/utils/Release-Package/EngineSupport.psm1 b/utils/Release-Package/EngineSupport.psm1 new file mode 100644 index 0000000..c3d29f2 --- /dev/null +++ b/utils/Release-Package/EngineSupport.psm1 @@ -0,0 +1,165 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) { + $gitToolsModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "GitTools.psm1" + if (Test-Path $gitToolsModulePath -PathType Leaf) { + Import-Module $gitToolsModulePath -Force + } +} + +if (-not (Get-Command Get-PluginStage -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +if (-not (Get-Command New-DotNetReleaseContext -ErrorAction SilentlyContinue)) { + $dotNetProjectSupportModulePath = Join-Path $PSScriptRoot "DotNetProjectSupport.psm1" + if (Test-Path $dotNetProjectSupportModulePath -PathType Leaf) { + Import-Module $dotNetProjectSupportModulePath -Force + } +} + +function Assert-WorkingTreeClean { + param( + [Parameter(Mandatory = $true)] + [bool]$IsReleaseBranch + ) + + $gitStatus = Get-GitStatusShort + if ($gitStatus) { + if ($IsReleaseBranch) { + Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." + Write-Log -Level "WARN" -Message "Uncommitted files:" + $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + exit 1 + } + + Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)." + return + } + + Write-Log -Level "OK" -Message " Working directory is clean." +} + +function Initialize-ReleaseStageContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$RemainingPlugins, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..." + $remoteTagExists = Test-RemoteTagExists -Tag $SharedSettings.Tag -Remote "origin" + if (-not $remoteTagExists) { + Write-Log -Level "WARN" -Message " Tag $($SharedSettings.Tag) not found on remote. Pushing..." + Push-TagToRemote -Tag $SharedSettings.Tag -Remote "origin" + } + else { + Write-Log -Level "OK" -Message " Tag exists on remote." + } + + if (-not $SharedSettings.PSObject.Properties['ReleaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.ReleaseDir)) { + $SharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $ArtifactsDirectory -Force + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$UtilsDir + ) + + $dotNetContext = New-DotNetReleaseContext -Plugins $Plugins -ScriptDir $ScriptDir + + $currentBranch = Get-CurrentBranch + $releaseBranches = @( + $Plugins | + Where-Object { Test-IsPublishPlugin -Plugin $_ } | + ForEach-Object { Get-PluginBranches -Plugin $_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + + $isReleaseBranch = $releaseBranches -contains $currentBranch + $isNonReleaseBranch = -not $isReleaseBranch + + Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch + + $version = $dotNetContext.Version + + if ($isReleaseBranch) { + $tag = Get-CurrentCommitTag -Version $version + + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." + exit 1 + } + + $tagVersion = $Matches[1] + if ($tagVersion -ne $version) { + Write-Error "Tag version ($tagVersion) does not match the project version ($version)." + Write-Log -Level "WARN" -Message " Either update the tag or the project version." + exit 1 + } + + Write-Log -Level "OK" -Message " Tag found: $tag (matches project version)" + } + else { + $tag = "v$version" + Write-Log -Level "INFO" -Message " Using version from the package project (no tag required on non-release branches)." + } + + return [pscustomobject]@{ + ScriptDir = $ScriptDir + UtilsDir = $UtilsDir + CurrentBranch = $currentBranch + Version = $version + Tag = $tag + ProjectFiles = $dotNetContext.ProjectFiles + ArtifactsDirectory = $dotNetContext.ArtifactsDirectory + IsReleaseBranch = $isReleaseBranch + IsNonReleaseBranch = $isNonReleaseBranch + ReleaseBranches = $releaseBranches + NonReleaseBranches = @() + PublishCompleted = $false + } +} + +function Get-PreferredReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [psobject]$EngineContext + ) + + if ($EngineContext.ReleaseBranches.Count -gt 0) { + return $EngineContext.ReleaseBranches[0] + } + + return "main" +} + +Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch diff --git a/utils/Release-Package/PluginSupport.psm1 b/utils/Release-Package/PluginSupport.psm1 new file mode 100644 index 0000000..326a16c --- /dev/null +++ b/utils/Release-Package/PluginSupport.psm1 @@ -0,0 +1,368 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function Import-PluginDependency { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName, + + [Parameter(Mandatory = $true)] + [string]$RequiredCommand + ) + + if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) { + return + } + + $moduleRoot = Split-Path $PSScriptRoot -Parent + $modulePath = Join-Path $moduleRoot "$ModuleName.psm1" + if (Test-Path $modulePath -PathType Leaf) { + # Import into the global session so the calling plugin can see the exported commands. + # Importing only into this module's scope would make the dependency invisible to the plugin. + Import-Module $modulePath -Force -Global -ErrorAction Stop + } + + if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { + throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'." + } +} + +function Get-ConfiguredPlugins { + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not $Settings.PSObject.Properties['Plugins'] -or $null -eq $Settings.Plugins) { + return @() + } + + # JSON can deserialize a single plugin as one object or multiple plugins as an array. + # Always return an array so the engine can loop without special-case logic. + if ($Settings.Plugins -is [System.Collections.IEnumerable] -and -not ($Settings.Plugins -is [string])) { + return @($Settings.Plugins) + } + + return @($Settings.Plugins) +} + +function Get-PluginStage { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['Stage'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.Stage)) { + return "Release" + } + + return [string]$Plugin.Stage +} + +function Get-PluginBranches { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) { + return @() + } + + # Strings are also IEnumerable in PowerShell, so exclude them or we would split into characters. + if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) { + return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) { + return @() + } + + return @([string]$Plugin.branches) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.Name)) { + return $false + } + + return @('GitHub', 'NuGet') -contains ([string]$Plugin.Name) +} + +function Get-PluginSettingValue { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) { + continue + } + + if (-not $plugin.PSObject.Properties[$PropertyName]) { + continue + } + + $value = $plugin.$PropertyName + if ($null -eq $value) { + continue + } + + if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { + continue + } + + return $value + } + + return $null +} + +function Get-PluginPathListSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $rawPaths = @() + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + + if ($null -eq $value) { + return @() + } + + # Same rule as above: treat a string as one path, not a char-by-char sequence. + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + $rawPaths += $value + } + else { + $rawPaths += $value + } + + $resolvedPaths = @() + foreach ($path in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$path)) { + continue + } + + $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path))) + } + + # Wrap again to stop PowerShell from unrolling a single-item array into a bare string. + return @($resolvedPaths) +} + +function Get-PluginPathSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { + return $null + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value))) +} + +function Get-ArchiveNamePattern { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) { + continue + } + + if (-not $plugin.Enabled) { + continue + } + + $allowedBranches = Get-PluginBranches -Plugin $plugin + if ($allowedBranches.Count -gt 0 -and -not ($allowedBranches -contains $CurrentBranch)) { + continue + } + + if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) { + return [string]$plugin.zipNamePattern + } + } + + return "release-{version}.zip" +} + +function Resolve-PluginModulePath { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory + ) + + $pluginFileName = "{0}.psm1" -f $Plugin.Name + $candidatePaths = @( + (Join-Path $PluginsDirectory $pluginFileName), + (Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName) + ) + + foreach ($candidatePath in $candidatePaths) { + if (Test-Path $candidatePath -PathType Leaf) { + return $candidatePath + } + } + + return $candidatePaths[0] +} + +function Test-PluginRunnable { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.Name)) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin entry with no Name." + } + return $false + } + + if (-not $Plugin.Enabled) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' (disabled)." + } + return $false + } + + if (Test-IsPublishPlugin -Plugin $Plugin) { + $allowedBranches = Get-PluginBranches -Plugin $Plugin + if ($allowedBranches.Count -eq 0) { + if ($WriteLogs) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' because no publish branches are configured." + } + return $false + } + + if (-not ($allowedBranches -contains $SharedSettings.CurrentBranch)) { + if ($WriteLogs) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' on branch '$($SharedSettings.CurrentBranch)'." + } + return $false + } + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory + if (-not (Test-Path $pluginModulePath -PathType Leaf)) { + if ($WriteLogs) { + Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath" + } + return $false + } + + return $true +} + +function New-PluginInvocationSettings { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $properties = @{} + foreach ($property in $Plugin.PSObject.Properties) { + $properties[$property.Name] = $property.Value + } + + # Plugins receive their own config plus a shared Context object that carries runtime artifacts. + $properties['Context'] = $SharedSettings + return [pscustomobject]$properties +} + +function Invoke-ConfiguredPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory, + + [Parameter(Mandatory = $false)] + [bool]$ContinueOnError = $true + ) + + if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -PluginsDirectory $PluginsDirectory -WriteLogs:$true)) { + return + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory + Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.Name)'..." + + try { + $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop + # Resolve Invoke-Plugin from the imported module explicitly so we call the plugin we just loaded, + # not some command with the same name from another module already in session. + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings + + & $invokeCommand -Settings $pluginSettings + Write-Log -Level "OK" -Message " Plugin '$($Plugin.Name)' completed." + } + catch { + Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.Name)' failed: $($_.Exception.Message)" + if (-not $ContinueOnError) { + exit 1 + } + } +} + +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStage, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/Release-Package/Release-Package.bat b/utils/Release-Package/Release-Package.bat new file mode 100644 index 0000000..6a4aba8 --- /dev/null +++ b/utils/Release-Package/Release-Package.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1" +pause diff --git a/utils/Release-Package/Release-Package.ps1 b/utils/Release-Package/Release-Package.ps1 new file mode 100644 index 0000000..8cef2be --- /dev/null +++ b/utils/Release-Package/Release-Package.ps1 @@ -0,0 +1,183 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven release engine. + +.DESCRIPTION + This script is the orchestration layer for release automation. + It loads scriptsettings.json, evaluates the configured plugins in order, + builds shared execution context, and invokes each plugin's Invoke-Plugin + entrypoint with that plugin's own settings object plus runtime context. + + The engine is intentionally generic: + - It does not embed release-provider-specific logic + - It preserves plugin execution order from scriptsettings.json + - It isolates plugin failures according to the stage/runtime policy + - It keeps shared orchestration helpers in dedicated support modules + +.REQUIREMENTS + Tools (Required): + - Shared support modules required by the engine + - Any commands required by configured plugins or support helpers + +.WORKFLOW + 1. Load and normalize plugin configuration + 2. Determine branch mode from configured plugin metadata + 3. Validate repository state and resolve the release version + 4. Build shared execution context + 5. Execute plugins one by one in configured order + 6. Initialize release-stage shared artifacts only when needed + 7. Report completion summary + +.USAGE + Configure plugin order and plugin settings in scriptsettings.json, then run: + pwsh -File .\Release-Package.ps1 + +.CONFIGURATION + All settings are stored in scriptsettings.json: + - Plugins: Ordered plugin definitions and plugin-specific settings + +.NOTES + Plugin-specific behavior belongs in the plugin modules, not in this engine. +#> + +# No parameters - behavior is controlled by configured plugin metadata: +# - non-release branches -> Run only the plugins allowed for those branches +# - release branches -> Require a matching tag and allow release-stage plugins + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +#region Import Modules + +$utilsDir = Split-Path $scriptDir -Parent + +# Import ScriptConfig module +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force + +# Import Logging module +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $loggingModulePath -Force +# Import PluginSupport module +$pluginSupportModulePath = Join-Path $scriptDir "PluginSupport.psm1" +if (-not (Test-Path $pluginSupportModulePath)) { + Write-Error "PluginSupport module not found at: $pluginSupportModulePath" + exit 1 +} + +Import-Module $pluginSupportModulePath -Force + +# Import DotNetProjectSupport module +$dotNetProjectSupportModulePath = Join-Path $scriptDir "DotNetProjectSupport.psm1" +if (-not (Test-Path $dotNetProjectSupportModulePath)) { + Write-Error "DotNetProjectSupport module not found at: $dotNetProjectSupportModulePath" + exit 1 +} + +Import-Module $dotNetProjectSupportModulePath -Force + +# Import EngineSupport module +$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1" +if (-not (Test-Path $engineSupportModulePath)) { + Write-Error "EngineSupport module not found at: $engineSupportModulePath" + exit 1 +} + +Import-Module $engineSupportModulePath -Force + +#endregion + +#region Load Settings +$settings = Get-ScriptSettings -ScriptDir $scriptDir +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +#endregion + +#region Configuration + +$pluginsDir = Join-Path $scriptDir "CorePlugins" + +#endregion + +#endregion + +#region Main + +Write-Log -Level "STEP" -Message "==================================================" +Write-Log -Level "STEP" -Message "RELEASE ENGINE" +Write-Log -Level "STEP" -Message "==================================================" + +#region Preflight + +$plugins = $configuredPlugins +$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir +Write-Log -Level "OK" -Message "All pre-flight checks passed!" +$sharedPluginSettings = $engineContext + +#endregion + +#region Plugin Execution + +$releaseStageInitialized = $false + +if ($plugins.Count -eq 0) { + Write-Log -Level "WARN" -Message "No plugins configured in scriptsettings.json." +} +else { + for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) { + $plugin = $plugins[$pluginIndex] + $pluginStage = Get-PluginStage -Plugin $plugin + + if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -WriteLogs:$false) { + $remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)]) + Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.ArtifactsDirectory -Version $engineContext.Version + $releaseStageInitialized = $true + } + } + + $continueOnError = $pluginStage -eq "Release" + Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -ContinueOnError:$continueOnError + } +} + +if (-not $releaseStageInitialized) { + $noReleasePluginsLogLevel = if ($engineContext.IsNonReleaseBranch) { "INFO" } else { "WARN" } + Write-Log -Level $noReleasePluginsLogLevel -Message "No release plugins executed for branch '$($engineContext.CurrentBranch)'." +} + +#endregion + +#region Summary +Write-Log -Level "OK" -Message "==================================================" +if ($engineContext.IsNonReleaseBranch) { + Write-Log -Level "OK" -Message "NON-RELEASE RUN COMPLETE" +} +else { + Write-Log -Level "OK" -Message "RELEASE COMPLETE" +} +Write-Log -Level "OK" -Message "==================================================" + +Write-Log -Level "INFO" -Message "Artifacts location: $($engineContext.ArtifactsDirectory)" + +if ($engineContext.IsNonReleaseBranch) { + $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext + Write-Log -Level "INFO" -Message "To execute release-stage plugins, rerun from an allowed release branch such as '$preferredReleaseBranch'." +} + +#endregion + +#endregion diff --git a/utils/Release-Package/scriptsettings.json b/utils/Release-Package/scriptsettings.json new file mode 100644 index 0000000..96fbd64 --- /dev/null +++ b/utils/Release-Package/scriptsettings.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release Package Script Settings", + "description": "Configuration file for Release-Package.ps1 script.", + "Plugins": [ + { + "Name": "DotNetTest", + "Stage": "Test", + "Enabled": true, + "project": "..\\..\\src\\MaksIT.UScheduler.Tests", + "resultsDir": "..\\..\\testResults" + }, + { + "Name": "QualityGate", + "Stage": "QualityGate", + "Enabled": true, + "coverageThreshold": 0, + "failOnVulnerabilities": true + }, + { + "Name": "DotNetPublish", + "Stage": "Build", + "Enabled": true, + "projectFiles": [ + "..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj", + "..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj" + ], + "artifactsDir": "..\\..\\release", + "bundleDir": "..\\..\\release\\bundle" + }, + { + "Name": "BundleCustomization", + "Stage": "Build", + "Enabled": true, + "bundleDir": "..\\..\\release\\bundle", + "scriptsPath": "..\\..\\src\\Scripts", + "launcher": { + "enabled": true, + "fileName": "Start-ScheduleManager.bat", + "targetProject": "scheduleManager" + }, + "projects": { + "scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj", + "uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj", + "scheduleManagerAppSettingsFile": "appsettings.json", + "uschedulerAppSettingsFile": "appsettings.json", + "scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\", + "uschedulerLogDir": "..\\..\\Logs", + "scriptsRelativeToExe": "..\\..\\Scripts" + } + }, + { + "Name": "CreateArchive", + "Stage": "Build", + "Enabled": true, + "zipNamePattern": "maksit.uscheduler-{version}.zip" + }, + { + "Name": "GitHub", + "Stage": "Release", + "branches": [ + "main" + ], + "githubToken": "GITHUB_MAKS_IT_COM", + "repository": "https://github.com/MAKS-IT-COM/uscheduler", + "releaseNotesFile": "..\\..\\CHANGELOG.md", + "releaseTitlePattern": "Release {version}" + }, + { + "Name": "CleanupArtifacts", + "Stage": "Release", + "Enabled": true, + "includePatterns": [ + "*" + ], + "excludePatterns": [ + "*.zip" + ] + } + ], + "_comments": { + "Plugins": { + "Name": "Plugin module file name in CorePlugins or CustomPlugins (for example, DotNetPublish -> CorePlugins/DotNetPublish.psm1).", + "Stage": "Execution phase. Supported values are Test, QualityGate, Build, and Release.", + "Enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.", + "branches": "Plugin-specific allowed branches. Omit to allow any branch.", + "project": "DotNetTest plugin only. Path to the test project directory, relative to the script folder.", + "resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.", + "projectFiles": "DotNetPublish or another producer plugin can define the project files used for version discovery and artifact creation.", + "artifactsDir": "DotNetPublish or another producer plugin can define the artifacts output directory, relative to the script folder.", + "bundleDir": "DotNetPublish and BundleCustomization plugins can define the staged bundle directory, relative to the script folder.", + "coverageThreshold": "QualityGate plugin only. Coverage threshold percent (0 disables threshold check).", + "failOnVulnerabilities": "QualityGate plugin only. If true, fail when vulnerable packages are detected.", + "githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.", + "repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.", + "releaseNotesFile": "GitHub plugin (or another notes consumer plugin) can define the release notes source file, relative to the script folder.", + "releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.", + "zipNamePattern": "GitHub plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.", + "scriptsPath": "BundleCustomization plugin only. Scripts folder copied into the staged bundle, relative to the script folder.", + "launcher": "BundleCustomization plugin only. Optional launcher batch file settings.", + "projects": "BundleCustomization plugin only. Project-specific appsettings rewrite settings." + } + } +} diff --git a/utils/Release-ToGitHub/Release-ToGitHub.bat b/utils/Release-ToGitHub/Release-ToGitHub.bat deleted file mode 100644 index 454936c..0000000 --- a/utils/Release-ToGitHub/Release-ToGitHub.bat +++ /dev/null @@ -1,9 +0,0 @@ -@echo off - -REM Change directory to the location of the script -cd /d %~dp0 - -REM Run GitHub release script -powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1" - -pause diff --git a/utils/Release-ToGitHub/Release-ToGitHub.ps1 b/utils/Release-ToGitHub/Release-ToGitHub.ps1 deleted file mode 100644 index 69a23b6..0000000 --- a/utils/Release-ToGitHub/Release-ToGitHub.ps1 +++ /dev/null @@ -1,695 +0,0 @@ -<# -.SYNOPSIS - Automated GitHub release script for MaksIT.UScheduler. - -.DESCRIPTION - Creates a GitHub release by performing the following steps: - - Pre-flight checks: - - Detects current branch (main or dev) - - On main: requires clean working directory; on dev: uncommitted changes allowed - - Reads version from .csproj (source of truth) - - On main: requires matching tag (vX.Y.Z format) - - Ensures version consistency with CHANGELOG.md - - Confirms GitHub CLI authentication via GH_TOKEN (main branch only) - - Test execution: - - Runs all unit tests via Run-Tests.ps1 - - Aborts release if any tests fail - - Displays coverage summary (line, branch, method) - - Build and release: - - Publishes the .NET project in Release configuration - - Copies Scripts folder into the release - - Creates a versioned ZIP archive - - Extracts release notes from CHANGELOG.md - - Pushes tag to remote if not already present (main branch only) - - Creates (or recreates) the GitHub release with assets (main branch only) - - Branch-based behavior (configurable in scriptsettings.json): - - On dev branch: Local build only, no tag required, uncommitted changes allowed - - On release branch: Full GitHub release, tag required, clean working directory required - - On other branches: Blocked - -.NOTES - File: Release-ToGitHub.ps1 - Author: Maksym Sadovnychyy (MAKS-IT) - Requires: dotnet, git, gh (GitHub CLI - required on main branch only) - - Configuration is loaded from scriptsettings.json in the same directory. - Set the GitHub token in an environment variable specified by github.tokenEnvVar. - -.EXAMPLE - .\Release-ToGitHub.ps1 - - Runs the release process using settings from scriptsettings.json. - On dev branch: creates local build (no tag needed). - On main branch: publishes to GitHub (tag required). - -.EXAMPLE - # Recommended workflow: - # 1. On dev branch: Update version in .csproj and CHANGELOG.md - # 2. Commit changes - # 3. Run: .\Release-ToGitHub.ps1 - # (creates local build for testing - no tag needed) - # 4. Test the build - # 5. Merge to main: git checkout main && git merge dev - # 6. Create tag: git tag v1.0.1 - # 7. Run: .\Release-ToGitHub.ps1 - # (publishes to GitHub) -#> - -# No parameters - behavior is controlled by current branch (configured in scriptsettings.json): -# - dev branch -> Local build only (no tag required, uncommitted changes allowed) -# - release branch -> Full release to GitHub (tag required, clean working directory) - -# Load settings from scriptsettings.json -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$settingsPath = Join-Path $scriptDir "scriptsettings.json" - -if (-not (Test-Path $settingsPath)) { - Write-Error "Settings file not found: $settingsPath" - exit 1 -} - -$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json - -# Import TestRunner module -$modulePath = Join-Path (Split-Path $scriptDir -Parent) "TestRunner.psm1" -if (-not (Test-Path $modulePath)) { - Write-Error "TestRunner module not found at: $modulePath" - exit 1 -} -Import-Module $modulePath -Force - -# Set GH_TOKEN from custom environment variable for GitHub CLI authentication -$tokenEnvVar = $settings.github.tokenEnvVar -$env:GH_TOKEN = [System.Environment]::GetEnvironmentVariable($tokenEnvVar) - -# Paths from settings (resolve relative to script directory) -$csprojPaths = @() -if ($settings.paths.csprojPath -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPath -is [string])) { - foreach ($path in $settings.paths.csprojPath) { - $csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path)) - } -} -else { - $csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.csprojPath)) -} - -$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir)) -$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir)) -$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath)) -$scriptsPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.scriptsPath)) -$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject)) - -# Release naming patterns -$zipNamePattern = $settings.release.zipNamePattern -$releaseTitlePattern = $settings.release.releaseTitlePattern - -# Branch configuration -$releaseBranch = $settings.branches.release -$devBranch = $settings.branches.dev - -# Project configuration (avoid hardcoding project names) -$projectsSettings = $settings.projects -$scheduleManagerCsprojEndsWith = $projectsSettings.scheduleManagerCsprojEndsWith -$uschedulerCsprojEndsWith = $projectsSettings.uschedulerCsprojEndsWith -$scheduleManagerAppSettingsFile = $projectsSettings.scheduleManagerAppSettingsFile -$uschedulerAppSettingsFile = $projectsSettings.uschedulerAppSettingsFile -$scheduleManagerServiceBinPath = $projectsSettings.scheduleManagerServiceBinPath -$uschedulerLogDir = $projectsSettings.uschedulerLogDir -$scriptsRelativeToExe = $projectsSettings.scriptsRelativeToExe - -# Helper: ensure required commands exist -function Assert-Command { - param([string]$cmd) - - if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { - Write-Error "Required command '$cmd' is missing. Aborting." - exit 1 - } -} - -# Helper: extract a csproj property (first match) -function Get-CsprojPropertyValue { - param( - [Parameter(Mandatory=$true)][xml]$csproj, - [Parameter(Mandatory=$true)][string]$propertyName - ) - - $propNode = $csproj.Project.PropertyGroup | - Where-Object { $_.$propertyName } | - Select-Object -First 1 - - if ($propNode) { - return $propNode.$propertyName - } - - return $null -} - -# Helper: resolve output assembly name for published exe -function Resolve-ProjectExeName { - param( - [Parameter(Mandatory=$true)][string]$projPath - ) - - [xml]$csproj = Get-Content $projPath - $assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName" - if ($assemblyName) { - return $assemblyName - } - - return [System.IO.Path]::GetFileNameWithoutExtension($projPath) -} - -# Helper: find csproj by configured suffix -function Find-CsprojByEndsWith { - param( - [Parameter(Mandatory=$true)][string[]]$paths, - [Parameter(Mandatory=$true)][string]$endsWith - ) - - if (-not $endsWith) { - return $null - } - - return $paths | Where-Object { $_ -like "*$endsWith" } | Select-Object -First 1 -} - -Assert-Command dotnet -Assert-Command git -# gh command check deferred until after branch detection (only needed on main branch) - -Write-Host "" -Write-Host "==================================================" -ForegroundColor Cyan -Write-Host "RELEASE BUILD" -ForegroundColor Cyan -Write-Host "==================================================" -ForegroundColor Cyan -Write-Host "" - -# ============================================================================== -# PRE-FLIGHT CHECKS -# ============================================================================== - -# 1. Detect current branch and determine release mode -Write-Host "Detecting current branch..." -ForegroundColor Gray -$currentBranch = git rev-parse --abbrev-ref HEAD 2>$null -if ($LASTEXITCODE -ne 0 -or -not $currentBranch) { - Write-Error "Could not determine current branch." - exit 1 -} - -$currentBranch = $currentBranch.Trim() -Write-Host " Branch: $currentBranch" -ForegroundColor Green - -$isDevBranch = $currentBranch -eq $devBranch -$isReleaseBranch = $currentBranch -eq $releaseBranch - -if (-not $isDevBranch -and -not $isReleaseBranch) { - Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch" - exit 1 -} - -if ($isDevBranch) { - Write-Host " Dev branch ($devBranch) - local build only (no GitHub release)." -ForegroundColor Yellow -} -else { - Write-Host " Release branch ($releaseBranch) - will publish to GitHub." -ForegroundColor Cyan - Assert-Command gh -} - -# 2. Check for uncommitted changes (required on main, allowed on dev) -$gitStatus = git status --porcelain 2>$null -if ($gitStatus) { - if ($isReleaseBranch) { - Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." - Write-Host "" - Write-Host "Uncommitted files:" -ForegroundColor Yellow - $gitStatus | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } - exit 1 - } - else { - Write-Host " Uncommitted changes detected (allowed on dev branch)." -ForegroundColor Yellow - } -} else { - Write-Host " Working directory is clean." -ForegroundColor Green -} - -# 3. Get version from csproj (source of truth) - -Write-Host "Reading version(s) from csproj(s)..." -ForegroundColor Gray -$projectVersions = @{} -foreach ($projPath in $csprojPaths) { - if (-not (Test-Path $projPath)) { - Write-Error "Csproj not found at: $projPath" - exit 1 - } - - [xml]$csproj = Get-Content $projPath - $version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version" - - if (-not $version) { - Write-Error "Version not found in $projPath" - exit 1 - } - - $projectVersions[$projPath] = $version - Write-Host " $([System.IO.Path]::GetFileName($projPath)): $version" -ForegroundColor Green -} - -# Use the first project's version as the main version for tag/release -$version = $projectVersions[$csprojPaths[0]] - -# 4. Handle tag based on branch -if ($isReleaseBranch) { - # Main branch: tag is required and must match version - Write-Host "Checking for tag on current commit..." -ForegroundColor Gray - $tag = git describe --tags --exact-match HEAD 2>$null - if ($LASTEXITCODE -ne 0 -or -not $tag) { - Write-Error "No tag found on current commit. Create a tag: git tag v$version" - exit 1 - } - - $tag = $tag.Trim() - - if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { - Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." - exit 1 - } - - $tagVersion = $Matches[1] - - if ($tagVersion -ne $version) { - Write-Error "Tag version ($tagVersion) does not match csproj version ($version)." - Write-Host " Either update the tag or the csproj version." -ForegroundColor Yellow - exit 1 - } - - Write-Host " Tag found: $tag (matches csproj)" -ForegroundColor Green -} -else { - # Dev branch: no tag required, use version from csproj - $tag = "v$version" - Write-Host " Using version from csproj (no tag required on dev)." -ForegroundColor Gray -} - -# 5. Verify CHANGELOG.md has matching version entry -Write-Host "Verifying CHANGELOG.md..." -ForegroundColor Gray -if (-not (Test-Path $changelogPath)) { - Write-Error "CHANGELOG.md not found at: $changelogPath" - exit 1 -} - -$changelog = Get-Content $changelogPath -Raw - -if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') { - Write-Error "No version entry found in CHANGELOG.md" - exit 1 -} - -$changelogVersion = $Matches[1] - -if ($changelogVersion -ne $version) { - Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)." - Write-Host " Update CHANGELOG.md or the csproj version." -ForegroundColor Yellow - exit 1 -} - -Write-Host " CHANGELOG.md version matches: v$changelogVersion" -ForegroundColor Green - -# 6. Check GitHub authentication (skip for local-only builds) -if (-not $isDevBranch) { - Write-Host "Checking GitHub authentication..." -ForegroundColor Gray - if (-not $env:GH_TOKEN) { - Write-Error "GH_TOKEN environment variable is not set. Set $tokenEnvVar and rerun." - exit 1 - } - - $authTest = gh api user 2>$null - if ($LASTEXITCODE -ne 0 -or -not $authTest) { - Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope." - exit 1 - } - Write-Host " GitHub CLI authenticated." -ForegroundColor Green -} -else { - Write-Host "Skipping GitHub authentication (local-only mode)." -ForegroundColor Gray -} - -Write-Host "" -Write-Host "All pre-flight checks passed!" -ForegroundColor Green -Write-Host "" - -# ============================================================================== -# RUN TESTS -# ============================================================================== - -Write-Host "Running tests..." -ForegroundColor Cyan - -# Run tests using TestRunner module -$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -Silent - -if (-not $testResult.Success) { - Write-Error "Tests failed. Release aborted." - Write-Host " Error: $($testResult.Error)" -ForegroundColor Red - exit 1 -} - -Write-Host " All tests passed!" -ForegroundColor Green -Write-Host " Line Coverage: $($testResult.LineRate)%" -ForegroundColor Gray -Write-Host " Branch Coverage: $($testResult.BranchRate)%" -ForegroundColor Gray -Write-Host " Method Coverage: $($testResult.MethodRate)%" -ForegroundColor Gray -Write-Host "" - -# ============================================================================== -# BUILD AND RELEASE -# ============================================================================== - -# 7. Prepare staging directory -Write-Host "Preparing staging directory..." -ForegroundColor Cyan -if (Test-Path $stagingDir) { - Remove-Item $stagingDir -Recurse -Force -} - -New-Item -ItemType Directory -Path $stagingDir | Out-Null - -$binDir = Join-Path $stagingDir "bin" - -# 8. Publish the project to staging/bin - -Write-Host "Publishing projects to bin folder..." -ForegroundColor Cyan -$publishSuccess = $true -$publishedProjects = @() - -foreach ($projPath in $csprojPaths) { - $projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath) - $projBinDir = Join-Path $binDir $projName - - dotnet publish $projPath -c Release -o $projBinDir - if ($LASTEXITCODE -ne 0) { - Write-Error "dotnet publish failed for $projName." - $publishSuccess = $false - } - else { - $exeBaseName = Resolve-ProjectExeName -projPath $projPath - $publishedProjects += [PSCustomObject]@{ - ProjPath = $projPath - ProjName = $projName - BinDir = $projBinDir - ExeBaseName = $exeBaseName - } - - Write-Host " Published $projName successfully to: $projBinDir" -ForegroundColor Green - } -} - -if (-not $publishSuccess) { - exit 1 -} - -# 9. Copy Scripts folder to staging -Write-Host "Copying Scripts folder..." -ForegroundColor Cyan -if (-not (Test-Path $scriptsPath)) { - Write-Error "Scripts folder not found at: $scriptsPath" - exit 1 -} - -$scriptsDestination = Join-Path $stagingDir "Scripts" -Copy-Item -Path $scriptsPath -Destination $scriptsDestination -Recurse -Write-Host " Scripts copied to: $scriptsDestination" -ForegroundColor Green - -Write-Host "Updating ScheduleManager appsettings with UScheduler path..." -ForegroundColor Cyan - -# 10. Update appsettings.json with scripts in disabled state -# Dynamically locate ScheduleManager appsettings based on settings.projects.scheduleManagerCsprojEndsWith -$scheduleManagerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith -if ($scheduleManagerCsprojPath) { - $scheduleManagerProjName = [System.IO.Path]::GetFileNameWithoutExtension($scheduleManagerCsprojPath) - $scheduleManagerBinDir = Join-Path $binDir $scheduleManagerProjName - $scheduleManagerAppSettingsPath = Join-Path $scheduleManagerBinDir $scheduleManagerAppSettingsFile - - if (Test-Path $scheduleManagerAppSettingsPath) { - $smAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json - if ($smAppSettings.USchedulerSettings) { - $smAppSettings.USchedulerSettings.ServiceBinPath = $scheduleManagerServiceBinPath - $jsonOutput = $smAppSettings | ConvertTo-Json -Depth 10 - Set-Content -Path $scheduleManagerAppSettingsPath -Value $jsonOutput -Encoding UTF8 - Write-Host " Updated ServiceBinPath in ScheduleManager appsettings" -ForegroundColor Green - } - else { - Write-Host " Warning: USchedulerSettings section not found in ScheduleManager appsettings" -ForegroundColor Yellow - } - } - else { - Write-Host " Warning: $scheduleManagerAppSettingsFile not found in $scheduleManagerProjName bin folder" -ForegroundColor Yellow - } -} -else { - Write-Host " Warning: ScheduleManager csproj not found in csprojPaths array" -ForegroundColor Yellow -} - -Write-Host "Updating UScheduler appsettings with new LogDir bundled scripts paths..." -ForegroundColor Cyan - -# Resolve UScheduler csproj by configured suffix (avoid hardcoded ScheduleManager exclusion) -$uschedulerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith -if ($uschedulerCsprojPath) { - $uschedulerProjName = [System.IO.Path]::GetFileNameWithoutExtension($uschedulerCsprojPath) - $uschedulerBinDir = Join-Path $binDir $uschedulerProjName - $appSettingsPath = Join-Path $uschedulerBinDir $uschedulerAppSettingsFile - - if (Test-Path $appSettingsPath) { - $appSettings = Get-Content $appSettingsPath -Raw | ConvertFrom-Json - - # Update LogDir for release - if ($appSettings.Configuration) { - $appSettings.Configuration.LogDir = $uschedulerLogDir - Write-Host " Updated LogDir in UScheduler appsettings" -ForegroundColor Green - } - else { - Write-Host " Warning: Configuration section not found in UScheduler appsettings" -ForegroundColor Yellow - } - - # Find all .ps1 files in Scripts folder (exclude utility scripts in subfolders named "Utilities") - $psScripts = Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse | - Where-Object { $_.Directory.Name -ne "Utilities" } | - ForEach-Object { - $relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\') - $scriptPath = "$scriptsRelativeToExe\$relativePath" - [PSCustomObject]@{ - Path = $scriptPath - IsSigned = $false - Disabled = $true - } - } - - # Add scripts to Powershell configuration - if ($psScripts) { - if (-not $appSettings.Configuration) { - $appSettings | Add-Member -MemberType NoteProperty -Name "Configuration" -Value ([PSCustomObject]@{}) - } - - $appSettings.Configuration.Powershell = @($psScripts) - $jsonOutput = $appSettings | ConvertTo-Json -Depth 10 - Set-Content -Path $appSettingsPath -Value $jsonOutput -Encoding UTF8 - Write-Host " Added $($psScripts.Count) PowerShell script(s) to appsettings (disabled)" -ForegroundColor Green - $psScripts | ForEach-Object { Write-Host " - $($_.Path)" -ForegroundColor Gray } - } - } - else { - Write-Host " Warning: $uschedulerAppSettingsFile not found in $uschedulerProjName bin folder" -ForegroundColor Yellow - } -} -else { - Write-Host " Warning: UScheduler csproj not found in csprojPaths array" -ForegroundColor Yellow -} - -# 11. Create launcher batch file (if enabled) -if ($settings.launcher -and $settings.launcher.enabled) { - Write-Host "Creating launcher batch file..." -ForegroundColor Cyan - - $launcherFileName = $settings.launcher.fileName - $targetProject = $settings.launcher.targetProject - - # Determine which project to launch - $targetCsprojPath = $null - $targetExeName = $null - $targetProjName = $null - - if ($targetProject -eq "scheduleManager") { - $targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith - } - elseif ($targetProject -eq "uscheduler") { - $targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith - } - else { - Write-Host " Warning: Unknown targetProject '$targetProject' in launcher settings" -ForegroundColor Yellow - } - - if ($targetCsprojPath) { - $targetProjName = [System.IO.Path]::GetFileNameWithoutExtension($targetCsprojPath) - $targetExeName = Resolve-ProjectExeName -projPath $targetCsprojPath - - $batPath = Join-Path $stagingDir $launcherFileName - $exePath = "%~dp0bin\$targetProjName\$targetExeName.exe" - - $batContent = @" -@echo off -start "" "$exePath" -"@ - - Set-Content -Path $batPath -Value $batContent -Encoding ASCII - Write-Host " Created launcher: $launcherFileName -> $exePath" -ForegroundColor Green - } - else { - Write-Host " Warning: Could not find target project for launcher" -ForegroundColor Yellow - } -} -else { - Write-Host "Skipping launcher batch file creation (disabled in settings)." -ForegroundColor Gray -} - -Write-Host "" - -# 12. Prepare release directory -if (!(Test-Path $releaseDir)) { - New-Item -ItemType Directory -Path $releaseDir | Out-Null -} - -# 13. Create zip file -$zipName = $zipNamePattern -replace '\{version\}', $version -$zipPath = Join-Path $releaseDir $zipName - -if (Test-Path $zipPath) { - Remove-Item $zipPath -Force -} - -Write-Host "Creating archive $zipName..." -ForegroundColor Cyan -Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force - -if (-not (Test-Path $zipPath)) { - Write-Error "Failed to create archive $zipPath" - exit 1 -} - -Write-Host " Archive created: $zipPath" -ForegroundColor Green - - -# 14. Extract release notes from CHANGELOG.md -Write-Host "Extracting release notes..." -ForegroundColor Cyan -$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)" -$match = [regex]::Match($changelog, $pattern) - -if (-not $match.Success) { - Write-Error "Changelog entry for version $version not found." - exit 1 -} - -$releaseNotes = $match.Value.Trim() -Write-Host " Release notes extracted." -ForegroundColor Green - -# 15. Get repository info -$remoteUrl = git config --get remote.origin.url -if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) { - Write-Error "Could not determine git remote origin URL." - exit 1 -} - -if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { - $owner = $matches['owner'] - $repoName = $matches['repo'] - $repo = "$owner/$repoName" -} else { - Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl" - exit 1 -} - -$releaseName = $releaseTitlePattern -replace '\{version\}', $version - -Write-Host "" -Write-Host "Release Summary:" -ForegroundColor Cyan -Write-Host " Repository: $repo" -ForegroundColor White -Write-Host " Tag: $tag" -ForegroundColor White -Write-Host " Title: $releaseName" -ForegroundColor White -Write-Host "" - -# 16. Check if tag is pushed to remote (skip on dev branch) -if (-not $isDevBranch) { - Write-Host "Verifying tag is pushed to remote..." -ForegroundColor Cyan - $remoteTag = git ls-remote --tags origin $tag 2>$null - if (-not $remoteTag) { - Write-Host " Tag $tag not found on remote. Pushing..." -ForegroundColor Yellow - git push origin $tag - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to push tag $tag to remote." - exit 1 - } - Write-Host " Tag pushed successfully." -ForegroundColor Green - } - else { - Write-Host " Tag exists on remote." -ForegroundColor Green - } - - # 17. Create or update GitHub release - Write-Host "Creating GitHub release..." -ForegroundColor Cyan - - # Check if release already exists - gh release view $tag --repo $repo 2>$null - if ($LASTEXITCODE -eq 0) { - Write-Host " Release $tag already exists. Deleting..." -ForegroundColor Yellow - gh release delete $tag --repo $repo --yes - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to delete existing release $tag." - exit 1 - } - } - - # Create new release using existing tag - $ghArgs = @( - "release", "create", $tag, $zipPath - "--repo", $repo - "--title", $releaseName - "--notes", $releaseNotes - ) - & gh @ghArgs - - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create GitHub release for tag $tag." - exit 1 - } - - Write-Host " GitHub release created successfully." -ForegroundColor Green -} -else { - Write-Host "Skipping GitHub release (dev branch)." -ForegroundColor Yellow -} - -# 18. Cleanup -if (Test-Path $stagingDir) { - Remove-Item $stagingDir -Recurse -Force - Write-Host " Cleaned up staging directory." -ForegroundColor Gray -} - -Write-Host "" -Write-Host "==================================================" -ForegroundColor Green -if ($isDevBranch) { - Write-Host "DEV BUILD COMPLETE" -ForegroundColor Green -} -else { - Write-Host "RELEASE COMPLETE" -ForegroundColor Green -} -Write-Host "==================================================" -ForegroundColor Green -Write-Host "" -if (-not $isDevBranch) { - Write-Host "Release URL: https://github.com/$repo/releases/tag/$tag" -ForegroundColor Cyan -} -Write-Host "Artifacts location: $releaseDir" -ForegroundColor Gray -if ($isDevBranch) { - Write-Host "" - Write-Host "To publish to GitHub, merge to main, tag, and run the script again:" -ForegroundColor Yellow - Write-Host " git checkout main" -ForegroundColor Yellow - Write-Host " git merge dev" -ForegroundColor Yellow - Write-Host " git tag v$version" -ForegroundColor Yellow - Write-Host " .\Release-ToGitHub.ps1" -ForegroundColor Yellow -} -Write-Host "" diff --git a/utils/Release-ToGitHub/scriptsettings.json b/utils/Release-ToGitHub/scriptsettings.json deleted file mode 100644 index 34f7a5f..0000000 --- a/utils/Release-ToGitHub/scriptsettings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Release to GitHub Script Settings", - "description": "Configuration file for Release-ToGitHub.ps1 script (automated GitHub release creation)", - "github": { - "tokenEnvVar": "GITHUB_MAKS_IT_COM" - }, - "branches": { - "release": "main", - "dev": "dev" - }, - "paths": { - "csprojPath": [ - "..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj", - "..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj" - ], - "stagingDir": "..\\..\\staging", - "releaseDir": "..\\..\\release", - "changelogPath": "..\\..\\CHANGELOG.md", - "scriptsPath": "..\\..\\src\\Scripts", - "testProject": "..\\..\\src\\MaksIT.UScheduler.Tests" - }, - "release": { - "zipNamePattern": "maksit.uscheduler-{version}.zip", - "releaseTitlePattern": "Release {version}" - }, - "launcher": { - "enabled": true, - "fileName": "Start-ScheduleManager.bat", - "targetProject": "scheduleManager" - }, - "projects": { - "scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj", - "uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj", - "scheduleManagerAppSettingsFile": "appsettings.json", - "uschedulerAppSettingsFile": "appsettings.json", - "scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\", - "uschedulerLogDir": "..\\..\\Logs", - "scriptsRelativeToExe": "..\\..\\Scripts" - }, - "_comments": { - "projects": { - "scheduleManagerCsprojEndsWith": "Used to detect ScheduleManager csproj from csprojPath list", - "uschedulerCsprojEndsWith": "Used to detect UScheduler csproj from csprojPath list", - "scheduleManagerAppSettingsFile": "Config file name inside published output for ScheduleManager", - "uschedulerAppSettingsFile": "Config file name inside published output for UScheduler", - "scheduleManagerServiceBinPath": "Value written into USchedulerSettings.ServiceBinPath in ScheduleManager config", - "uschedulerLogDir": "Value written into Configuration.LogDir in UScheduler config", - "scriptsRelativeToExe": "Scripts base path relative to executable folder (used for appsettings script list)" - }, - "shortcut": { - "enabled": "If true, creates a .lnk in staging root", - "projectRole": "Which project to point the shortcut to (ScheduleManager or UScheduler)", - "fileName": "Shortcut file name in staging root" - } - } -} diff --git a/utils/ScriptConfig.psm1 b/utils/ScriptConfig.psm1 new file mode 100644 index 0000000..738cd5c --- /dev/null +++ b/utils/ScriptConfig.psm1 @@ -0,0 +1,35 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-ScriptSettings { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $false)] + [string]$SettingsFileName = "scriptsettings.json" + ) + + $settingsPath = Join-Path $ScriptDir $SettingsFileName + + if (-not (Test-Path $settingsPath -PathType Leaf)) { + Write-Error "Settings file not found: $settingsPath" + exit 1 + } + + return Get-Content $settingsPath -Raw | ConvertFrom-Json +} + +function Assert-Command { + param( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { + Write-Error "Required command '$Command' is missing. Aborting." + exit 1 + } +} + +Export-ModuleMember -Function Get-ScriptSettings, Assert-Command diff --git a/utils/TestRunner.psm1 b/utils/TestRunner.psm1 index 864fcb5..f382b24 100644 --- a/utils/TestRunner.psm1 +++ b/utils/TestRunner.psm1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS PowerShell module for running tests with code coverage. @@ -8,9 +11,40 @@ .NOTES Author: MaksIT - Usage: Import-Module .\TestRunner.psm1 + Usage: pwsh -Command "Import-Module .\TestRunner.psm1" #> +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-TestRunnerLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + function Invoke-TestsWithCoverage { <# .SYNOPSIS @@ -22,6 +56,9 @@ function Invoke-TestsWithCoverage { .PARAMETER Silent Suppress console output (for JSON consumption). + .PARAMETER ResultsDirectory + Optional fixed directory where test result files are written. + .PARAMETER KeepResults Keep the TestResults folder after execution. @@ -38,7 +75,7 @@ function Invoke-TestsWithCoverage { .EXAMPLE $result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests" - if ($result.Success) { Write-Host "Line coverage: $($result.LineRate)%" } + if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" } #> param( [Parameter(Mandatory = $true)] @@ -46,6 +83,8 @@ function Invoke-TestsWithCoverage { [switch]$Silent, + [string]$ResultsDirectory, + [switch]$KeepResults ) @@ -60,7 +99,12 @@ function Invoke-TestsWithCoverage { } } - $ResultsDir = Join-Path $TestProjectDir "TestResults" + if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) { + $ResultsDir = Join-Path $TestProjectDir "TestResults" + } + else { + $ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory) + } # Clean previous results if (Test-Path $ResultsDir) { @@ -68,8 +112,8 @@ function Invoke-TestsWithCoverage { } if (-not $Silent) { - Write-Host "Running tests with code coverage..." -ForegroundColor Cyan - Write-Host " Test Project: $TestProjectDir" -ForegroundColor Gray + Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..." + Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir" } # Run tests with coverage collection @@ -111,8 +155,8 @@ function Invoke-TestsWithCoverage { } if (-not $Silent) { - Write-Host "Coverage file found: $($CoverageFile.FullName)" -ForegroundColor Green - Write-Host "Parsing coverage data..." -ForegroundColor Cyan + Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)" + Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..." } # Parse coverage data from Cobertura XML diff --git a/utils/Update-RepoUtils/Update-RepoUtils.bat b/utils/Update-RepoUtils/Update-RepoUtils.bat new file mode 100644 index 0000000..8ff94ac --- /dev/null +++ b/utils/Update-RepoUtils/Update-RepoUtils.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1" +pause diff --git a/utils/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/Update-RepoUtils/Update-RepoUtils.ps1 new file mode 100644 index 0000000..0e8a20d --- /dev/null +++ b/utils/Update-RepoUtils/Update-RepoUtils.ps1 @@ -0,0 +1,325 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Refreshes a local maksit-repoutils copy from GitHub. + +.DESCRIPTION + This script clones the configured repository into a temporary directory, + refreshes the parent directory of this script, preserves existing + scriptsettings.json files in subfolders, and copies the cloned source + contents into that parent directory. + + All configuration is stored in scriptsettings.json. + +.EXAMPLE + pwsh -File .\Update-RepoUtils.ps1 + +.NOTES + CONFIGURATION (scriptsettings.json): + - dryRun: If true, logs the planned update without modifying files + - repository.url: Git repository to clone + - repository.sourceSubdirectory: Folder copied into the target directory + - repository.preserveFileName: Existing file name to preserve in subfolders + - repository.cloneDepth: Depth used for git clone + - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh +#> + +[CmdletBinding()] +param( + [switch]$ContinueAfterSelfUpdate, + [string]$TargetDirectoryOverride, + [string]$ClonedSourceDirectoryOverride, + [string]$TemporaryRootOverride +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$utilsDir = Split-Path $scriptDir -Parent + +# Refresh the parent directory that contains the shared modules and sibling tools. +$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) { + Split-Path $scriptDir -Parent +} +else { + [System.IO.Path]::GetFullPath($TargetDirectoryOverride) +} +$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path) +$selfUpdateDirectory = 'Update-RepoUtils' + +#region Import Modules + +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +$repositoryUrl = $settings.repository.url +$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false } +$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' } +$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptsettings.json' } +$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 } +$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) { + @( + $settings.repository.skippedRelativeDirectories | + ForEach-Object { + ([string]$_).Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar) + } + ) +} +else { + @([System.IO.Path]::Combine('Release-Package', 'CustomPlugins')) +} + +#endregion + +#region Validate CLI Dependencies + +Assert-Command git +Assert-Command pwsh + +if ([string]::IsNullOrWhiteSpace($repositoryUrl)) { + Write-Error "repository.url is required in scriptsettings.json." + exit 1 +} + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Update RepoUtils Script" +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Target directory: $targetDirectory" +Write-Log -Level "INFO" -Message "Dry run: $dryRun" + +$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride) +$temporaryRoot = if ($ownsTemporaryRoot) { + Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N')) +} +else { + [System.IO.Path]::GetFullPath($TemporaryRootOverride) +} + +try { + $clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) { + Write-LogStep "Cloning latest repository snapshot..." + & git clone --depth $cloneDepth $repositoryUrl $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "git clone failed with exit code $LASTEXITCODE." + } + Write-Log -Level "OK" -Message "Repository cloned" + + Join-Path $temporaryRoot $sourceSubdirectory + } + else { + [System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride) + } + + if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) { + throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory" + } + + if (-not $ContinueAfterSelfUpdate) { + if ($dryRun) { + Write-LogStep "Dry run self-update summary" + Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater" + } + else { + Write-LogStep "Refreshing updater files..." + $selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar) + $isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + + $_.Name -ne $preserveFileName -and + ($isRootFile -or $isUpdaterFile) + } + + foreach ($sourceFile in $selfUpdateFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + Write-Log -Level "OK" -Message "Updater files refreshed" + } + + if ($dryRun) { + Write-LogStep "Dry run bootstrap completed" + Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed" + } + else { + Write-LogStep "Relaunching the updated updater..." + & pwsh -File $currentScriptPath ` + -ContinueAfterSelfUpdate ` + -TargetDirectoryOverride $targetDirectory ` + -ClonedSourceDirectoryOverride $clonedSourceDirectory ` + -TemporaryRootOverride $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "Relaunched updater failed with exit code $LASTEXITCODE." + } + + Write-Log -Level "OK" -Message "Bootstrap phase completed" + return + } + } + + $preservedFiles = @() + $updatePhaseSkippedDirectories = $skippedRelativeDirectories + $selfUpdateDirectory + $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue + if ($existingPreservedFiles) { + foreach ($file in $existingPreservedFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) + $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $preservedFiles += [pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + } + + if (-not $dryRun) { + Copy-Item -Path $file.FullName -Destination $backupPath -Force + } + } + Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" + } + else { + Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" + } + + if ($dryRun) { + Write-LogStep "Dry run summary" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" + if ($preservedFiles.Count -gt 0) { + $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " + Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList" + } + Write-Log -Level "OK" -Message "Dry run completed. No files were modified." + return + } + + Write-LogStep "Cleaning target directory..." + $filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + + $_.Name -ne $preserveFileName -and + -not $isInSkippedDirectory + } + + foreach ($file in $filesToRemove) { + Remove-Item -Path $file.FullName -Force + } + + $directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory | + Sort-Object { $_.FullName.Length } -Descending + + foreach ($directory in $directoriesToRemove) { + $remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue + if (-not $remainingItems) { + Remove-Item -Path $directory.FullName -Force + } + } + Write-Log -Level "OK" -Message "Target directory cleaned" + + Write-LogStep "Copying refreshed source files..." + $sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + + -not $isInSkippedDirectory + } + + foreach ($sourceFile in $sourceFilesToCopy) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + $skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory + if (Test-Path -Path $skippedSourcePath) { + Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" + } + } + Write-Log -Level "OK" -Message "Source files copied" + + if ($preservedFiles.Count -gt 0) { + foreach ($preservedFile in $preservedFiles) { + if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) { + continue + } + + $restorePath = Join-Path $targetDirectory $preservedFile.RelativePath + $restoreDirectory = Split-Path -Parent $restorePath + if (-not (Test-Path -Path $restoreDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null + } + + Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force + } + Write-Log -Level "OK" -Message "$preserveFileName files restored" + } + + Write-Log -Level "OK" -Message "========================================" + Write-Log -Level "OK" -Message "Update completed successfully!" + Write-Log -Level "OK" -Message "========================================" +} +finally { + if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) { + Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +#endregion diff --git a/utils/Update-RepoUtils/scriptsettings.json b/utils/Update-RepoUtils/scriptsettings.json new file mode 100644 index 0000000..9d55393 --- /dev/null +++ b/utils/Update-RepoUtils/scriptsettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Update RepoUtils Script Settings", + "description": "Configuration for the Update-RepoUtils utility.", + "dryRun": true, + "repository": { + "url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git", + "sourceSubdirectory": "src", + "preserveFileName": "scriptsettings.json", + "cloneDepth": 1, + "skippedRelativeDirectories": [ + "Release-Package/CustomPlugins" + ] + } +}