(feature): update repo utils

This commit is contained in:
Maksym Sadovnychyy 2026-03-01 12:12:11 +01:00
parent 6b95fcd0b2
commit 6820ec90af
58 changed files with 3792 additions and 1085 deletions

View File

@ -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:

112
README.md
View File

@ -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.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7%">
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7%">
<title>Branch Coverage: 7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 15.2%">
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 15.2%">
<title>Line Coverage: 15.2%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 38.8%">
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 38.8%">
<title>Method Coverage: 38.8%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,8 @@
namespace MaksIT.PSScriptGateway.Configuration;
public sealed class PSScriptGatewayOptions
{
public const string SectionName = "PSScriptGateway";
public string ScriptsRoot { get; set; } = @"..\..\..\..\Scripts";
}

View File

@ -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<IActionResult> 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<ScriptExecutionRequest> 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<string?> 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;
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="2.0.0" />
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@MaksIT.PSScriptGateway_HostAddress = http://localhost:5078
GET {{MaksIT.PSScriptGateway_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,12 @@
namespace MaksIT.PSScriptGateway.Models;
public sealed record ScriptExecutionRequest(
string HttpMethod,
string RequestPath,
string ScriptPath,
string? ContentType,
string? Body,
IReadOnlyDictionary<string, string[]> Query,
IReadOnlyDictionary<string, string[]> Headers,
IReadOnlyDictionary<string, object?> RouteValues
);

View File

@ -0,0 +1,7 @@
namespace MaksIT.PSScriptGateway.Models;
public sealed record ScriptExecutionResponse(
int StatusCode,
object? Value,
IReadOnlyList<string> Messages
);

View File

@ -0,0 +1,15 @@
using MaksIT.PSScriptGateway.Configuration;
using MaksIT.PSScriptGateway.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<PSScriptGatewayOptions>(
builder.Configuration.GetSection(PSScriptGatewayOptions.SectionName));
builder.Services.AddSingleton<IPSScriptGatewayService, PSScriptGatewayService>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,8 @@
using MaksIT.PSScriptGateway.Models;
namespace MaksIT.PSScriptGateway.Services;
public interface IPSScriptGatewayService
{
Task<ScriptExecutionResponse> ExecuteAsync(string scriptName, ScriptExecutionRequest request, CancellationToken cancellationToken);
}

View File

@ -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<PSScriptGatewayService> _logger;
private readonly string _scriptsRoot;
public PSScriptGatewayService(
ILogger<PSScriptGatewayService> logger,
IOptions<PSScriptGatewayOptions> options)
{
_logger = logger;
_scriptsRoot = ResolveScriptsRoot(options.Value.ScriptsRoot);
}
public async Task<ScriptExecutionResponse> 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<PSObject> 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<PSObject> 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<string> 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<object?>()
.Select(item => item?.ToString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.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;
}
}

View File

@ -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<object?>);
private static readonly Type NonGenericResultType = typeof(Result);
private static readonly IReadOnlyDictionary<int, string> StatusMethodNames = Enum
.GetValues<HttpStatusCode>()
.Distinct()
.ToDictionary(code => (int)code, code => code.ToString());
public static IActionResult ToActionResult(int statusCode, object? value, IReadOnlyList<string> 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<object?> 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<object?> 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.";
}
}

View File

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"PSScriptGateway": {
"ScriptsRoot": "..\\..\\..\\..\\Scripts"
}
}

View File

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"PSScriptGateway": {
"ScriptsRoot": "..\\..\\..\\..\\Scripts"
},
"AllowedHosts": "*"
}

View File

@ -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

View File

@ -0,0 +1,7 @@
<Solution>
<Project Path="MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.csproj" />
<Project Path="MaksIT.UScheduler.ScheduleManager/MaksIT.UScheduler.ScheduleManager.csproj" />
<Project Path="MaksIT.UScheduler.Shared/MaksIT.UScheduler.Shared.csproj" />
<Project Path="MaksIT.UScheduler.Tests/MaksIT.UScheduler.Tests.csproj" />
<Project Path="MaksIT.UScheduler/MaksIT.UScheduler.csproj" />
</Solution>

View File

@ -38,7 +38,7 @@
<Link>%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\badges\**\*" Link="badges\%(RecursiveDir)%(Filename)%(Extension)">
<None Include="..\..\assets\**\*" Link="assets\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

View File

@ -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

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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."
}
}

268
utils/GitTools.psm1 Normal file
View File

@ -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

70
utils/Logging.psm1 Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
return "$($matches['owner'])/$($matches['repo'])"
}
if ($repoSource -match "^(?<owner>[^/]+)/(?<repo>[^/]+)$") {
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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@

View File

@ -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/<ProjectName>
- 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
pause

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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

View File

@ -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 "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.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 ""

View File

@ -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"
}
}
}

35
utils/ScriptConfig.psm1 Normal file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
pause

View File

@ -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

View File

@ -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"
]
}
}