(feature): update repo utils
@ -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
@ -1,10 +1,12 @@
|
||||
# MaksIT Unified Scheduler Service
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
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.
|
||||
|
||||

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

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

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

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

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
BIN
assets/MaksIT.UScheduler.ScheduleManager_HjRiCd1jnn.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
assets/MaksIT.UScheduler.ScheduleManager_M7ZQAkaymD.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
assets/MaksIT.UScheduler.ScheduleManager_MiY7biadQg.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
assets/MaksIT.UScheduler.ScheduleManager_aYFXXtK8V2.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@ -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 |
@ -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 |
@ -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 |
BIN
assets/explorer_6Ai8GBZ7xg.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
@ -0,0 +1,8 @@
|
||||
namespace MaksIT.PSScriptGateway.Configuration;
|
||||
|
||||
public sealed class PSScriptGatewayOptions
|
||||
{
|
||||
public const string SectionName = "PSScriptGateway";
|
||||
|
||||
public string ScriptsRoot { get; set; } = @"..\..\..\..\Scripts";
|
||||
}
|
||||
65
src/MaksIT.PSScriptGateway/Controllers/PSScriptController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.csproj
Normal 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>
|
||||
6
src/MaksIT.PSScriptGateway/MaksIT.PSScriptGateway.http
Normal file
@ -0,0 +1,6 @@
|
||||
@MaksIT.PSScriptGateway_HostAddress = http://localhost:5078
|
||||
|
||||
GET {{MaksIT.PSScriptGateway_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
12
src/MaksIT.PSScriptGateway/Models/ScriptExecutionRequest.cs
Normal 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
|
||||
);
|
||||
@ -0,0 +1,7 @@
|
||||
namespace MaksIT.PSScriptGateway.Models;
|
||||
|
||||
public sealed record ScriptExecutionResponse(
|
||||
int StatusCode,
|
||||
object? Value,
|
||||
IReadOnlyList<string> Messages
|
||||
);
|
||||
15
src/MaksIT.PSScriptGateway/Program.cs
Normal 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();
|
||||
14
src/MaksIT.PSScriptGateway/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
using MaksIT.PSScriptGateway.Models;
|
||||
|
||||
namespace MaksIT.PSScriptGateway.Services;
|
||||
|
||||
public interface IPSScriptGatewayService
|
||||
{
|
||||
Task<ScriptExecutionResponse> ExecuteAsync(string scriptName, ScriptExecutionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
192
src/MaksIT.PSScriptGateway/Services/PSScriptGatewayService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
108
src/MaksIT.PSScriptGateway/Services/ResultMapper.cs
Normal 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.";
|
||||
}
|
||||
}
|
||||
11
src/MaksIT.PSScriptGateway/appsettings.Development.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"PSScriptGateway": {
|
||||
"ScriptsRoot": "..\\..\\..\\..\\Scripts"
|
||||
}
|
||||
}
|
||||
12
src/MaksIT.PSScriptGateway/appsettings.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"PSScriptGateway": {
|
||||
"ScriptsRoot": "..\\..\\..\\..\\Scripts"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@ -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
|
||||
7
src/MaksIT.UScheduler.slnx
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
@ -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
|
||||
121
utils/Release-Package/CorePlugins/CleanupArtifacts.psm1
Normal 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
|
||||
93
utils/Release-Package/CorePlugins/CreateArchive.psm1
Normal 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
|
||||
99
utils/Release-Package/CorePlugins/DotNetPack.psm1
Normal 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
|
||||
71
utils/Release-Package/CorePlugins/DotNetPublish.psm1
Normal 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
|
||||
72
utils/Release-Package/CorePlugins/DotNetTest.psm1
Normal 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
|
||||
232
utils/Release-Package/CorePlugins/GitHub.psm1
Normal 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
|
||||
67
utils/Release-Package/CorePlugins/NuGet.psm1
Normal 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
|
||||
119
utils/Release-Package/CorePlugins/QualityGate.psm1
Normal 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
|
||||
1
utils/Release-Package/CustomPlugins/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
314
utils/Release-Package/CustomPlugins/BundleCustomization.psm1
Normal 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
|
||||
110
utils/Release-Package/DotNetProjectSupport.psm1
Normal 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
|
||||
165
utils/Release-Package/EngineSupport.psm1
Normal 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
|
||||
368
utils/Release-Package/PluginSupport.psm1
Normal 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
|
||||
3
utils/Release-Package/Release-Package.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
|
||||
pause
|
||||
183
utils/Release-Package/Release-Package.ps1
Normal 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
|
||||
104
utils/Release-Package/scriptsettings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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 ""
|
||||
@ -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
@ -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
|
||||
@ -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
|
||||
|
||||
3
utils/Update-RepoUtils/Update-RepoUtils.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
|
||||
pause
|
||||
325
utils/Update-RepoUtils/Update-RepoUtils.ps1
Normal 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
|
||||
15
utils/Update-RepoUtils/scriptsettings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||