(build): wire repoutils release engine and refresh contributor docs, update dependencies

This commit is contained in:
Maksym Sadovnychyy 2026-06-28 19:08:18 +02:00
parent 7794a446a9
commit bf565a6e64
69 changed files with 3060 additions and 2046 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
[*.cs]
indent_size = 2
indent_style = space
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true

View File

@ -5,7 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.0.2 - 2026-03-01
## [1.0.3] - 2026-06-28
### Added
- **RepoUtils release engine**: `utils/engines/release/` with `BundleCustomization` plugin for bundled ZIP releases
- **`.editorconfig`**: repo-wide C# formatting (2-space indent, MaksIT brace style)
### Changed
- **PSScriptGateway**: MaksIT.Core logging/middleware, layered config, camelCase JSON, and `Result<T>` → `ToActionResult()` flow
- **README** and **CONTRIBUTING**: RepoUtils test/release entry points, commit format, and coverage badge workflow
### Fixed
- **Release packaging**: `utils/Invoke-ReleasePackage.bat` now targets a wired release engine (previously missing `utils/engines/release/`)
- **ScheduleManager version**: aligned with `MaksIT.UScheduler` at `1.0.3`
## [1.0.2] - 2026-03-01
### Fixed
- **PowerShell module loading**: Scripts with module dependencies now execute correctly when running as Windows service
@ -14,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Resolves "AuthorizationManager check failed" errors for modules downloaded from the internet
- Supports `.psm1` and `.psd1` module files in script directory and subfolders
## v1.0.1 - 2026-02-15
## [1.0.1] - 2026-02-15
### Added
- **CLI service management**: Added command-line arguments for service installation and management
@ -53,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Background services use `Task.WhenAll` to launch all tasks simultaneously
- **Unit test improvements**: Refactored tests to use `IOptionsMonitor<Configuration>` for better coverage and reliability
## v1.0.0 - 2025-12-06
## [1.0.0] - 2025-12-06
### Major Changes
- Migrate of the Unified Scheduler Service in .NET 10 (previously .NET 8).
@ -78,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!--
Template for new releases:
## v1.x.x - YYYY-MM-DD
## [1.x.x] - YYYY-MM-DD
### Added
- New features

View File

@ -4,21 +4,14 @@ Thank you for your interest in contributing to MaksIT.UScheduler!
## Table of Contents
- [Contributing to MaksIT.UScheduler](#contributing-to-maksituscheduler)
- [Table of Contents](#table-of-contents)
- [Development Setup](#development-setup)
- [Branch Strategy](#branch-strategy)
- [Making Changes](#making-changes)
- [Versioning](#versioning)
- [Release Process](#release-process)
- [Prerequisites](#prerequisites)
- [Version Files](#version-files)
- [Branch-Based Release Behavior](#branch-based-release-behavior)
- [Development Build Workflow](#development-build-workflow)
- [Production Release Workflow](#production-release-workflow)
- [Release Script Details](#release-script-details)
- [Changelog Guidelines](#changelog-guidelines)
- [AI-Powered Changelog Generation (Optional)](#ai-powered-changelog-generation-optional)
- [Development Setup](#development-setup)
- [Branch Strategy](#branch-strategy)
- [Making Changes](#making-changes)
- [Commit Message Format](#commit-message-format)
- [Versioning](#versioning)
- [Build and Test](#build-and-test)
- [Release Process](#release-process)
- [Changelog Guidelines](#changelog-guidelines)
---
@ -56,11 +49,35 @@ Thank you for your interest in contributing to MaksIT.UScheduler!
2. Make your changes
3. Update `CHANGELOG.md` with your changes
4. Update version in `.csproj` if needed
5. Test your changes locally using dev tags
5. Run tests locally (`utils/Invoke-TestEngine.bat`)
6. Submit a pull request to `dev`
---
## Commit Message Format
```
(type): description
```
| Type | Description |
|------|-------------|
| `(feature):` | New feature or enhancement |
| `(bugfix):` | Bug fix |
| `(refactor):` | Code refactoring without functional changes |
| `(perf):` | Performance improvement |
| `(test):` | Add or update tests |
| `(docs):` | Documentation-only changes |
| `(build):` | Build system, dependencies, or packaging |
| `(ci):` | CI/CD or automation changes |
| `(style):` | Formatting or non-functional style changes |
| `(revert):` | Revert a previous commit |
| `(chore):` | General maintenance |
Guidelines: lowercase description, no trailing period.
---
## Versioning
This project follows [Semantic Versioning](https://semver.org/):
@ -69,7 +86,31 @@ This project follows [Semantic Versioning](https://semver.org/):
- **MINOR** - New functionality (backwards compatible)
- **PATCH** - Bug fixes (backwards compatible)
Version format: `X.Y.Z` (e.g., `1.0.1`)
Version format: `X.Y.Z` (e.g., `1.0.2`)
Before a release, keep versions aligned across:
1. **`src/MaksIT.UScheduler/MaksIT.UScheduler.csproj`** — canonical `<Version>`
2. **`src/MaksIT.UScheduler.ScheduleManager/MaksIT.UScheduler.ScheduleManager.csproj`**
3. **`CHANGELOG.md`** — matching version header
---
## Build and Test
### Tests and coverage badges
```powershell
.\utils\Invoke-TestEngine.bat
```
The test engine runs `MaksIT.UScheduler.Tests`, applies the quality gate, and refreshes coverage badges in `assets/badges/`. Commit updated SVG files when coverage changes.
### Sync RepoUtils
```powershell
.\utils\Update-RepoUtils.bat
```
---
@ -77,133 +118,43 @@ Version format: `X.Y.Z` (e.g., `1.0.1`)
### Prerequisites
- .NET SDK installed
- .NET SDK
- PowerShell 7+
- Git CLI
- GitHub CLI (`gh`) - required only for production releases
- GitHub token set in environment variable (configured in `scriptsettings.json`)
- GitHub CLI (`gh`) — required for production releases on `main`
- `GITHUB_MAKS_IT_COM` environment variable (see `utils/engines/release/scriptSettings.json`)
### Version Files
### Development build (`dev` branch)
Before creating a release, ensure version consistency across:
No git tag required. Uncommitted changes are allowed.
1. **`.csproj`** - Update `<Version>` element:
```xml
<Version>1.0.1</Version>
```
2. **`CHANGELOG.md`** - Add version entry at the top:
```markdown
## v1.0.1
### Added
- New feature description
### Fixed
- Bug fix description
```
### Branch-Based Release Behavior
The release script behavior is controlled by the current branch (configurable in `scriptsettings.json`):
| Branch | Tag Required | Uncommitted Changes | Behavior |
|--------|--------------|---------------------|----------|
| Dev (`dev`) | No | Allowed | Local build only (version from .csproj) |
| Release (`main`) | Yes | Not allowed | Full release to GitHub |
| Other | - | - | Blocked |
Branch names can be customized in `scriptsettings.json`:
```json
"branches": {
"release": "main",
"dev": "dev"
}
```
### Development Build Workflow
Test builds on the `dev` branch - no tag needed:
```bash
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
```powershell
# 1. Update version in .csproj files and CHANGELOG.md
git checkout dev
# 2. Commit your changes
git add .
git commit -m "Prepare v1.0.1 release"
# 3. Run the release script (no tag needed!)
cd src/scripts/Release-ToGitHub
.\Release-ToGitHub.ps1
# Output: DEV BUILD COMPLETE
# Creates: release/maksit.uscheduler-1.0.1.zip (local only)
# 2. Run the release engine
.\utils\Invoke-ReleasePackage.bat
```
### Production Release Workflow
Output: `release/maksit.uscheduler-{version}.zip` (local only; GitHub publish is skipped on non-release branches).
When ready to publish, merge to `main`, create tag, and run:
### Production release (`main` branch)
```bash
# 1. Merge to main
```powershell
# 1. Merge to main and ensure a clean working tree
git checkout main
git merge dev
# 2. Create tag (required on main)
git tag v1.0.1
# 2. Create tag matching the .csproj version
git tag v1.0.2
# 3. Run the release script
cd src/scripts/Release-ToGitHub
.\Release-ToGitHub.ps1
# Output: RELEASE COMPLETE
# Creates: release/maksit.uscheduler-1.0.1.zip
# Pushes tag to GitHub
# Creates GitHub release with assets
# 3. Run the release engine
.\utils\Invoke-ReleasePackage.bat
```
### Release Script Details
On `main`, `ReleasePublishGuard` requires an exact tag on `HEAD` matching the .NET project version. The engine publishes tests, builds the bundle, creates the ZIP, and pushes a GitHub release when guard requirements are met.
The `Release-ToGitHub.ps1` script performs these 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 tag matching the version
- Ensures `CHANGELOG.md` has matching version entry
- Checks GitHub CLI authentication (main branch only)
**Build process:**
- Publishes .NET project in Release configuration
- Copies `Scripts` folder into the release
- Creates versioned ZIP archive
- Extracts release notes from `CHANGELOG.md`
**GitHub release (main branch only):**
- Pushes tag to remote if not present
- Creates (or recreates) GitHub release with assets
**Configuration:**
The script reads settings from `scriptsettings.json`:
```json
{
"github": {
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
},
"paths": {
"csprojPath": "..\\..\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
"changelogPath": "..\\..\\..\\CHANGELOG.md",
"releaseDir": "..\\..\\release"
},
"release": {
"zipNamePattern": "maksit.uscheduler-{version}.zip",
"releaseTitlePattern": "Release {version}"
}
}
```
Configuration: `utils/engines/release/scriptSettings.json`
---
@ -212,7 +163,7 @@ The script reads settings from `scriptsettings.json`:
Follow [Keep a Changelog](https://keepachangelog.com/) format:
```markdown
## v1.0.1
## [1.0.2] - YYYY-MM-DD
### Added
- New features
@ -220,17 +171,8 @@ Follow [Keep a Changelog](https://keepachangelog.com/) format:
### Changed
- Changes to existing functionality
### Deprecated
- Features to be removed in future
### Removed
- Removed features
### Fixed
- Bug fixes
### Security
- Security-related changes
```
---

View File

@ -7,6 +7,8 @@ Designed for system administrators — and also for those who *feel like* system
> **Tip:** A graphical [Schedule Manager UI](#schedule-manager-ui) is included for easy service registration, script scheduling, and log viewing — no command-line required.
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and release workflow.
---
## Table of Contents
@ -433,6 +435,14 @@ The project includes a comprehensive test suite using **xUnit** and **Moq** for
### Running Tests
Use the RepoUtils test engine (recommended):
```powershell
.\utils\Invoke-TestEngine.bat
```
Or run tests directly:
```powershell
# Run all tests
dotnet test src/MaksIT.UScheduler.Tests
@ -443,22 +453,13 @@ dotnet test src/MaksIT.UScheduler.Tests --verbosity normal
### Code Coverage
Coverage badges are generated locally using [ReportGenerator](https://github.com/danielpalme/ReportGenerator).
**Prerequisites:**
Coverage badges under `assets/badges/` are generated by the test engine (`CoverageBadges` plugin). After changing tests or coverage, rerun:
```powershell
dotnet tool install --global dotnet-reportgenerator-globaltool
.\utils\Invoke-TestEngine.bat
```
**Generate coverage report and badges:**
```powershell
.\src\scripts\Run-Coverage\Run-Coverage.ps1
# With HTML report opened in browser
.\src\scripts\Run-Coverage\Run-Coverage.ps1 -OpenReport
```
Commit updated SVG files under `assets/badges/` with the test or release work.
### Test Structure

View File

@ -1,5 +1,6 @@
using MaksIT.PSScriptGateway.Models;
using MaksIT.PSScriptGateway.Services;
using MaksIT.Results;
using Microsoft.AspNetCore.Mvc;
namespace MaksIT.PSScriptGateway.Controllers;
@ -20,8 +21,8 @@ public sealed class PSScriptController : ControllerBase
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);
var result = await _scriptGatewayService.ExecuteAsync(scriptName, request, cancellationToken);
return result.ToActionResult();
}
private async Task<ScriptExecutionRequest> BuildRequestAsync(string scriptName, CancellationToken cancellationToken)

View File

@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@ -11,8 +12,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="2.0.0" />
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="MaksIT.Results" Version="2.0.3" />
<PackageReference Include="System.Management.Automation" Version="7.6.3" />
</ItemGroup>
</Project>

View File

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

View File

@ -1,15 +1,50 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MaksIT.Core.Logging;
using MaksIT.Core.Webapi.Middlewares;
using MaksIT.PSScriptGateway.Configuration;
using MaksIT.PSScriptGateway.Services;
using MaksIT.Results.Mvc;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.Logging.AddConsoleLogger();
var appsettingsPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "configMap", "appsettings.json");
if (File.Exists(appsettingsPath))
configuration.AddJsonFile(appsettingsPath, optional: false, reloadOnChange: true);
var secretsPaths = new[] {
Path.Combine(Path.DirectorySeparatorChar.ToString(), "secrets", "appsecrets.json"),
Path.Combine(builder.Environment.ContentRootPath ?? "", "secrets", "appsecrets.json"),
Path.Combine(builder.Environment.ContentRootPath ?? "", "appsecrets.json")
};
foreach (var secretsPath in secretsPaths) {
if (!string.IsNullOrEmpty(secretsPath) && File.Exists(secretsPath)) {
configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
break;
}
}
static void ConfigureJsonSerializerOptions(JsonSerializerOptions options) {
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
}
builder.Services.Configure<PSScriptGatewayOptions>(
builder.Configuration.GetSection(PSScriptGatewayOptions.SectionName));
configuration.GetSection(PSScriptGatewayOptions.SectionName));
builder.Services.AddSingleton<IPSScriptGatewayService, PSScriptGatewayService>();
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options => ConfigureJsonSerializerOptions(options.JsonSerializerOptions));
builder.Services.AddOptions<JsonOptions>().Configure(o =>
ConfigureJsonSerializerOptions(o.JsonSerializerOptions));
var app = builder.Build();
app.UseMiddleware<ErrorHandlingMiddleware>();
app.MapControllers();
app.Run();

View File

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

View File

@ -3,6 +3,7 @@ using System.Collections.ObjectModel;
using System.Management.Automation;
using MaksIT.PSScriptGateway.Configuration;
using MaksIT.PSScriptGateway.Models;
using MaksIT.Results;
using MaksIT.UScheduler.Shared.Helpers;
using Microsoft.Extensions.Options;
@ -21,14 +22,14 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
_scriptsRoot = ResolveScriptsRoot(options.Value.ScriptsRoot);
}
public async Task<ScriptExecutionResponse> ExecuteAsync(
public async Task<Result<object?>> ExecuteAsync(
string scriptName,
ScriptExecutionRequest request,
CancellationToken cancellationToken)
{
var scriptPath = ResolveScriptPath(scriptName);
if (scriptPath is null)
return new ScriptExecutionResponse(StatusCodes.Status404NotFound, null, ["Script not found."]);
return Result<object?>.NotFound(null, "Script not found.");
using var powerShell = PowerShell.Create();
using var registration = cancellationToken.Register(() => {
@ -58,15 +59,18 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
output = await Task.Run(() => powerShell.Invoke(), cancellationToken);
}
catch (OperationCanceledException) {
return new ScriptExecutionResponse(StatusCodes.Status499ClientClosedRequest, null, ["The request was canceled."]);
return ScriptExecutionResultFactory.FromResponse(
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]);
return Result<object?>.InternalServerError(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."]);
return Result<object?>.InternalServerError(null, "Unhandled script execution error.");
}
if (TryParseScriptResponse(output, out var response))
@ -78,13 +82,12 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
.Where(message => !string.IsNullOrWhiteSpace(message))
.ToArray();
return new ScriptExecutionResponse(
StatusCodes.Status500InternalServerError,
null,
errors.Length == 0 ? ["Script execution failed."] : errors);
return errors.Length == 0
? Result<object?>.InternalServerError(null, "Script execution failed.")
: Result<object?>.InternalServerError(null, errors);
}
return new ScriptExecutionResponse(StatusCodes.Status204NoContent, null, ["No content."]);
return Result<object?>.NoContent(null, "No content.");
}
private string ResolveScriptsRoot(string scriptsRoot)
@ -112,9 +115,9 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
return File.Exists(combinedPath) ? combinedPath : null;
}
private static bool TryParseScriptResponse(Collection<PSObject> output, out ScriptExecutionResponse response)
private static bool TryParseScriptResponse(Collection<PSObject> output, out Result<object?> response)
{
response = default!;
response = null!;
if (output.Count == 0)
return false;
@ -124,13 +127,13 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
var values = output.Select(UnwrapValue).ToArray();
var payload = values.Length == 1 ? values[0] : values;
response = new ScriptExecutionResponse(StatusCodes.Status200OK, payload, ["OK"]);
response = Result<object?>.Ok(payload, ["OK"]);
return true;
}
private static bool TryParseExplicitResponse(PSObject psObject, out ScriptExecutionResponse response)
private static bool TryParseExplicitResponse(PSObject psObject, out Result<object?> response)
{
response = default!;
response = null!;
if (!TryReadIntProperty(psObject, "StatusCode", out var statusCode))
return false;
@ -141,7 +144,7 @@ public sealed class PSScriptGatewayService : IPSScriptGatewayService
?? ReadProperty(psObject, "Data")
?? ReadProperty(psObject, "Result");
response = new ScriptExecutionResponse(statusCode, value, messages);
response = ScriptExecutionResultFactory.FromResponse(statusCode, value, messages);
return true;
}

View File

@ -1,11 +1,10 @@
using System.Net;
using System.Reflection;
using MaksIT.Results;
using Microsoft.AspNetCore.Mvc;
namespace MaksIT.PSScriptGateway.Services;
internal static class ResultMapper
internal static class ScriptExecutionResultFactory
{
private static readonly Type GenericResultType = typeof(Result<object?>);
private static readonly Type NonGenericResultType = typeof(Result);
@ -14,7 +13,7 @@ internal static class ResultMapper
.Distinct()
.ToDictionary(code => (int)code, code => code.ToString());
public static IActionResult ToActionResult(int statusCode, object? value, IReadOnlyList<string> messages)
public static Result<object?> FromResponse(int statusCode, object? value, IReadOnlyList<string> messages)
{
var normalizedStatusCode = NormalizeStatusCode(statusCode);
var resolvedMessages = messages.Count == 0
@ -22,15 +21,12 @@ internal static class ResultMapper
: messages.ToArray();
if (TryBuildGenericResult(normalizedStatusCode, value, resolvedMessages, out var genericResult))
return genericResult.ToActionResult();
return genericResult;
if (value is null && TryBuildResult(normalizedStatusCode, resolvedMessages, out var result))
return result.ToActionResult();
return result.ToResultOfType<object?>(null);
if (value is null)
return new StatusCodeResult(normalizedStatusCode);
return new ObjectResult(value) { StatusCode = normalizedStatusCode };
return Result<object?>.Ok(value, resolvedMessages);
}
private static bool TryBuildGenericResult(int statusCode, object? value, string[] messages, out Result<object?> result)

View File

@ -3,7 +3,8 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Version>1.0.1</Version>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<Version>1.0.3</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
@ -15,7 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup>

View File

@ -8,8 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@ -11,12 +11,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.*">
<PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -2,7 +2,8 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.0.2</Version>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<Version>1.0.3</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
@ -17,16 +18,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.3" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.5.3" />
<PackageReference Include="Microsoft.WSMan.Management" Version="7.5.4" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.3" />
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.6.3" />
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.6.3" />
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.6.3" />
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.6.3" />
<PackageReference Include="Microsoft.WSMan.Management" Version="7.6.3" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.9" />
<PackageReference Include="System.Management.Automation" Version="7.6.3" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1" %*
pause

View File

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

View File

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

View File

@ -1,234 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Generates SVG coverage badges for README.
.DESCRIPTION
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
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)
- colorThresholds : Coverage percentages for badge colors
Badge colors based on coverage:
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
- yellow (>=20%), orange (>=10%), red (<10%)
If openReport is true, ReportGenerator is required:
dotnet tool install -g dotnet-reportgenerator-globaltool
.EXAMPLE
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.
.NOTES
Author: MaksIT
Requires: .NET SDK, Coverlet (included in test project)
#>
$ErrorActionPreference = "Stop"
# Get the directory of the current script (for loading settings and relative paths)
$ScriptDir = $PSScriptRoot
$UtilsDir = Split-Path $ScriptDir -Parent
#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 $testRunnerModulePath -Force
# 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
# 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
#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))
# Ensure badges directory exists
if (-not (Test-Path $BadgesDir)) {
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
}
#endregion
#region Helpers
# Maps a coverage percentage to a shields.io color using configured thresholds.
function Get-BadgeColor {
param([double]$percentage)
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
if ($percentage -ge $thresholds.green) { return "green" }
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
if ($percentage -ge $thresholds.yellow) { return "yellow" }
if ($percentage -ge $thresholds.orange) { return "orange" }
return "red"
}
# Builds a shields.io-like SVG badge string for one metric.
function New-Badge {
param(
[string]$label,
[string]$value,
[string]$color
)
# Calculate widths (approximate character width of 6.5px for the font)
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
$totalWidth = $labelWidth + $valueWidth
$labelX = $labelWidth / 2
$valueX = $labelWidth + ($valueWidth / 2)
$colorMap = @{
"brightgreen" = "#4c1"
"green" = "#97ca00"
"yellowgreen" = "#a4a61d"
"yellow" = "#dfb317"
"orange" = "#fe7d37"
"red" = "#e05d44"
}
$hexColor = $colorMap[$color]
if (-not $hexColor) { $hexColor = "#9f9f9f" }
return @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
#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 utf8NoBOM
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
}
#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
if ($OpenReport -and $coverage.CoverageFile) {
Write-LogStep -Message "Generating HTML report..."
Assert-Command reportgenerator
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
$ReportDir = Join-Path $ResultsDir "report"
$reportGenArgs = @(
"-reports:$($coverage.CoverageFile)"
"-targetdir:$ReportDir"
"-reporttypes:Html"
)
& reportgenerator @reportGenArgs
$IndexFile = Join-Path $ReportDir "index.html"
if (Test-Path $IndexFile) {
Start-Process $IndexFile
}
Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing."
}
#endregion
#endregion

View File

@ -1,44 +0,0 @@
{
"$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": "..\\..\\assets\\badges"
},
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
],
"colorThresholds": {
"brightgreen": 80,
"green": 60,
"yellowgreen": 40,
"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."
}
}

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
pause

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
pause

View File

@ -1,99 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET pack plugin for producing package artifacts.
.DESCRIPTION
This plugin creates package output for the release pipeline.
It packs the configured .NET project, resolves the generated
package artifacts, and publishes them into shared runtime context
for later plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# Load this globally only as a fallback. Re-importing PluginSupport in its own execution path
# can invalidate commands already resolved by the release engine.
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$packageProjectPath = $null
$releaseArchiveInputs = @()
Assert-Command dotnet
if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPack plugin requires project files in the shared context."
}
$outputDir = $artifactsDirectory
if (!(Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# The release context guarantees ProjectFiles is an array, so index 0 is the first project path,
# not the first character of a string.
$packageProjectPath = $projectFiles[0]
Write-Log -Level "STEP" -Message "Packing NuGet package..."
dotnet pack $packageProjectPath -c Release -o $outputDir --nologo `
-p:IncludeSymbols=true `
-p:SymbolPackageFormat=snupkg
if ($LASTEXITCODE -ne 0) {
throw "dotnet pack failed for $packageProjectPath."
}
# dotnet pack can leave older packages in the artifacts directory.
# Pick the newest file matching the current version rather than assuming a clean folder.
$packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" |
Where-Object {
$_.Name -like "*$version*.nupkg" -and
$_.Name -notlike "*.symbols.nupkg" -and
$_.Name -notlike "*.snupkg"
} |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $packageFile) {
throw "Could not locate generated NuGet package for version $version in: $outputDir"
}
Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
$releaseArchiveInputs = @($packageFile.FullName)
$symbolsPackageFile = Get-ChildItem -Path $outputDir -Filter "*.snupkg" |
Where-Object { $_.Name -like "*$version*.snupkg" } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($symbolsPackageFile) {
Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)"
$releaseArchiveInputs += $symbolsPackageFile.FullName
}
else {
Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $packageFile -Force
$sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,72 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET test plugin for executing automated tests.
.DESCRIPTION
This plugin resolves the configured .NET test project and optional
results directory, runs tests through TestRunner, and stores
the resulting test metrics in shared runtime context.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# Same fallback pattern as the other plugins: use the existing shared module if it is already loaded.
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$testProjectSetting = $pluginSettings.project
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.ScriptDir
if ([string]::IsNullOrWhiteSpace($testProjectSetting)) {
throw "DotNetTest plugin requires 'project' in scriptsettings.json."
}
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testProjectSetting))
$testResultsDir = $null
if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting))
}
Write-Log -Level "STEP" -Message "Running tests..."
# Build a splatted hashtable so optional arguments can be added without duplicating the call site.
$invokeTestParams = @{
TestProjectPath = $testProjectPath
Silent = $true
}
if ($testResultsDir) {
$invokeTestParams.ResultsDirectory = $testResultsDir
}
$testResult = Invoke-TestsWithCoverage @invokeTestParams
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName TestResult -NotePropertyValue $testResult -Force
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,119 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Quality gate plugin for validating release readiness.
.DESCRIPTION
This plugin evaluates quality constraints using shared test
results and project files. It enforces coverage thresholds
and checks for vulnerable packages before release plugins run.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Test-VulnerablePackagesInternal {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
$findings = @()
foreach ($projectPath in $ProjectFiles) {
Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))"
$output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1
if ($LASTEXITCODE -ne 0) {
throw "dotnet list package --vulnerable failed for $projectPath."
}
$outputText = ($output | Out-String)
if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") {
$findings += [pscustomobject]@{
Project = $projectPath
Output = $outputText.Trim()
}
}
}
return $findings
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$coverageThresholdSetting = $pluginSettings.coverageThreshold
$failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities
$projectFiles = $sharedSettings.ProjectFiles
$testResult = $null
if ($sharedSettings.PSObject.Properties['TestResult']) {
$testResult = $sharedSettings.TestResult
}
if ($null -eq $testResult) {
throw "QualityGate plugin requires test results. Run the DotNetTest plugin first."
}
$coverageThreshold = 0
if ($null -ne $coverageThresholdSetting) {
$coverageThreshold = [double]$coverageThresholdSetting
}
if ($coverageThreshold -gt 0) {
Write-Log -Level "STEP" -Message "Checking coverage threshold..."
if ([double]$testResult.LineRate -lt $coverageThreshold) {
throw "Line coverage $($testResult.LineRate)% is below the configured threshold of $coverageThreshold%."
}
Write-Log -Level "OK" -Message " Coverage threshold met: $($testResult.LineRate)% >= $coverageThreshold%"
}
else {
Write-Log -Level "WARN" -Message "Skipping coverage threshold check (disabled)."
}
Assert-Command dotnet
$failOnVulnerabilities = $true
if ($null -ne $failOnVulnerabilitiesSetting) {
$failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting
}
$vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles
if ($vulnerabilities.Count -eq 0) {
Write-Log -Level "OK" -Message " No vulnerable packages detected."
return
}
foreach ($finding in $vulnerabilities) {
Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))"
$finding.Output -split "`r?`n" | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace($_)) {
Write-Log -Level "WARN" -Message " $_"
}
}
}
if ($failOnVulnerabilities) {
throw "Vulnerable packages were detected and failOnVulnerabilities is enabled."
}
Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,314 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Customizes the staged release bundle after build artifacts are produced.
.DESCRIPTION
Recreates the legacy release bundle layout by:
- publishing all configured .NET projects into bundle/bin/<ProjectName>
- copying the Scripts folder into bundle/Scripts
- rewriting appsettings.json for the bundled runtime layout
- optionally creating a launcher batch file
The plugin then updates the shared release context so CreateArchive zips the
fully prepared bundle directory instead of the raw publish output.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Resolve-PluginPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
}
function Get-ProjectPropertyValueInternal {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
}
return $null
}
function Resolve-ProjectExeNameInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectPath
)
[xml]$csproj = Get-Content $ProjectPath
$assemblyName = Get-ProjectPropertyValueInternal -Csproj $csproj -PropertyName "AssemblyName"
if (-not [string]::IsNullOrWhiteSpace([string]$assemblyName)) {
return [string]$assemblyName
}
return [System.IO.Path]::GetFileNameWithoutExtension($ProjectPath)
}
function Find-ProjectBySuffixInternal {
param(
[Parameter(Mandatory = $true)]
[object[]]$PublishedProjects,
[Parameter(Mandatory = $false)]
[string]$Suffix
)
if ([string]::IsNullOrWhiteSpace($Suffix)) {
return $null
}
return $PublishedProjects |
Where-Object { $_.ProjectPath -like "*$Suffix" } |
Select-Object -First 1
}
function Set-JsonFileContentInternal {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$jsonOutput = $Value | ConvertTo-Json -Depth 20
Set-Content -Path $Path -Value $jsonOutput -Encoding UTF8
}
function Ensure-NotePropertyInternal {
param(
[Parameter(Mandatory = $true)]
[object]$Target,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[object]$PropertyValue
)
if ($Target.PSObject.Properties[$PropertyName]) {
$Target.$PropertyName = $PropertyValue
return
}
$Target | Add-Member -MemberType NoteProperty -Name $PropertyName -Value $PropertyValue
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $sharedSettings.ProjectFiles.Count -eq 0) {
throw "BundleCustomization plugin requires project files in the shared context."
}
$scriptDir = $sharedSettings.ScriptDir
$projectFiles = @($sharedSettings.ProjectFiles)
$bundleDirectory = if ($pluginSettings.PSObject.Properties['bundleDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.bundleDir)) {
Resolve-PluginPath -Path ([string]$pluginSettings.bundleDir) -BasePath $scriptDir
}
else {
Join-Path $sharedSettings.ArtifactsDirectory "bundle"
}
if (-not $pluginSettings.PSObject.Properties['scriptsPath'] -or [string]::IsNullOrWhiteSpace([string]$pluginSettings.scriptsPath)) {
throw "BundleCustomization plugin requires a scriptsPath setting."
}
$scriptsSourcePath = Resolve-PluginPath -Path ([string]$pluginSettings.scriptsPath) -BasePath $scriptDir
if (-not (Test-Path $scriptsSourcePath -PathType Container)) {
throw "Scripts folder not found: $scriptsSourcePath"
}
Assert-Command dotnet
Write-Log -Level "STEP" -Message "Preparing customized release bundle..."
if (Test-Path $bundleDirectory) {
Remove-Item -Path $bundleDirectory -Recurse -Force
}
New-Item -ItemType Directory -Path $bundleDirectory | Out-Null
$binDirectory = Join-Path $bundleDirectory "bin"
New-Item -ItemType Directory -Path $binDirectory | Out-Null
$publishedProjects = @()
foreach ($projectPath in $projectFiles) {
if (-not (Test-Path $projectPath -PathType Leaf)) {
throw "Project file not found: $projectPath"
}
$projectName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath)
$projectBinDirectory = Join-Path $binDirectory $projectName
Write-Log -Level "STEP" -Message " Publishing $projectName into bundle..."
dotnet publish $projectPath -c Release -o $projectBinDirectory --nologo
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for $projectPath."
}
$exeBaseName = Resolve-ProjectExeNameInternal -ProjectPath $projectPath
$publishedProjects += [pscustomobject]@{
ProjectPath = $projectPath
ProjectName = $projectName
BinDirectory = $projectBinDirectory
ExeBaseName = $exeBaseName
}
Write-Log -Level "OK" -Message " Published: $projectBinDirectory"
}
$scriptsDestination = Join-Path $bundleDirectory "Scripts"
Copy-Item -Path $scriptsSourcePath -Destination $scriptsDestination -Recurse
Write-Log -Level "OK" -Message " Scripts copied: $scriptsDestination"
if ($pluginSettings.PSObject.Properties['projects'] -and $null -ne $pluginSettings.projects) {
$projectConfig = $pluginSettings.projects
$scheduleManagerProject = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$projectConfig.scheduleManagerCsprojEndsWith)
if ($null -ne $scheduleManagerProject) {
$scheduleManagerAppSettingsFile = [string]$projectConfig.scheduleManagerAppSettingsFile
$scheduleManagerAppSettingsPath = Join-Path $scheduleManagerProject.BinDirectory $scheduleManagerAppSettingsFile
if (Test-Path $scheduleManagerAppSettingsPath -PathType Leaf) {
$scheduleManagerAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json
if ($scheduleManagerAppSettings.PSObject.Properties['USchedulerSettings'] -and $null -ne $scheduleManagerAppSettings.USchedulerSettings) {
Ensure-NotePropertyInternal -Target $scheduleManagerAppSettings.USchedulerSettings -PropertyName "ServiceBinPath" -PropertyValue ([string]$projectConfig.scheduleManagerServiceBinPath)
Set-JsonFileContentInternal -Path $scheduleManagerAppSettingsPath -Value $scheduleManagerAppSettings
Write-Log -Level "OK" -Message " Updated ScheduleManager appsettings."
}
else {
Write-Log -Level "WARN" -Message " ScheduleManager appsettings has no USchedulerSettings section."
}
}
else {
Write-Log -Level "WARN" -Message " ScheduleManager appsettings not found: $scheduleManagerAppSettingsPath"
}
}
else {
Write-Log -Level "WARN" -Message " ScheduleManager project not found in configured project files."
}
$uSchedulerProject = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$projectConfig.uschedulerCsprojEndsWith)
if ($null -ne $uSchedulerProject) {
$uSchedulerAppSettingsFile = [string]$projectConfig.uschedulerAppSettingsFile
$uSchedulerAppSettingsPath = Join-Path $uSchedulerProject.BinDirectory $uSchedulerAppSettingsFile
if (Test-Path $uSchedulerAppSettingsPath -PathType Leaf) {
$uSchedulerAppSettings = Get-Content $uSchedulerAppSettingsPath -Raw | ConvertFrom-Json
if (-not $uSchedulerAppSettings.PSObject.Properties['Configuration'] -or $null -eq $uSchedulerAppSettings.Configuration) {
Ensure-NotePropertyInternal -Target $uSchedulerAppSettings -PropertyName "Configuration" -PropertyValue ([pscustomobject]@{})
}
Ensure-NotePropertyInternal -Target $uSchedulerAppSettings.Configuration -PropertyName "LogDir" -PropertyValue ([string]$projectConfig.uschedulerLogDir)
$powerShellScripts = @(
Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse -File |
Where-Object { $_.Directory.Name -ne "Utilities" } |
ForEach-Object {
$relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\')
$scriptPath = "{0}\{1}" -f ([string]$projectConfig.scriptsRelativeToExe), $relativePath
[pscustomobject]@{
Path = $scriptPath
IsSigned = $false
Disabled = $true
}
}
)
Ensure-NotePropertyInternal -Target $uSchedulerAppSettings.Configuration -PropertyName "Powershell" -PropertyValue $powerShellScripts
Set-JsonFileContentInternal -Path $uSchedulerAppSettingsPath -Value $uSchedulerAppSettings
Write-Log -Level "OK" -Message " Updated UScheduler appsettings."
if ($powerShellScripts.Count -gt 0) {
Write-Log -Level "INFO" -Message " Added $($powerShellScripts.Count) bundled script entries."
}
}
else {
Write-Log -Level "WARN" -Message " UScheduler appsettings not found: $uSchedulerAppSettingsPath"
}
}
else {
Write-Log -Level "WARN" -Message " UScheduler project not found in configured project files."
}
}
if ($pluginSettings.PSObject.Properties['launcher'] -and $null -ne $pluginSettings.launcher -and $pluginSettings.launcher.enabled) {
$launcherSettings = $pluginSettings.launcher
$launcherTarget = $null
switch ([string]$launcherSettings.targetProject) {
"scheduleManager" {
$launcherTarget = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$pluginSettings.projects.scheduleManagerCsprojEndsWith)
}
"uscheduler" {
$launcherTarget = Find-ProjectBySuffixInternal -PublishedProjects $publishedProjects -Suffix ([string]$pluginSettings.projects.uschedulerCsprojEndsWith)
}
default {
Write-Log -Level "WARN" -Message " Unknown launcher targetProject '$([string]$launcherSettings.targetProject)'."
}
}
if ($null -ne $launcherTarget) {
$launcherPath = Join-Path $bundleDirectory ([string]$launcherSettings.fileName)
$launcherExePath = "%~dp0bin\$($launcherTarget.ProjectName)\$($launcherTarget.ExeBaseName).exe"
$launcherContent = @"
@echo off
start "" "$launcherExePath"
"@
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
Write-Log -Level "OK" -Message " Created launcher: $launcherPath"
}
else {
Write-Log -Level "WARN" -Message " Launcher target project could not be resolved."
}
}
$sharedSettings | Add-Member -NotePropertyName BundleDirectory -NotePropertyValue $bundleDirectory -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue @($bundleDirectory) -Force
Write-Log -Level "OK" -Message "Customized release bundle ready: $bundleDirectory"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,110 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
if (-not (Get-Command Get-PluginPathListSetting -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force
}
}
function Get-DotNetProjectPropertyValue {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
# SDK-style .csproj files can have multiple PropertyGroup nodes.
# Use the first group that defines the requested property.
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
}
return $null
}
function Get-DotNetProjectVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
Write-Log -Level "INFO" -Message "Reading version(s) from .NET project files..."
$projectVersions = @{}
foreach ($projectPath in $ProjectFiles) {
if (-not (Test-Path $projectPath -PathType Leaf)) {
Write-Error "Project file not found at: $projectPath"
exit 1
}
if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") {
Write-Error "Configured project file is not a .csproj file: $projectPath"
exit 1
}
[xml]$csproj = Get-Content $projectPath
$version = Get-DotNetProjectPropertyValue -Csproj $csproj -PropertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projectPath"
exit 1
}
$projectVersions[$projectPath] = $version
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version"
}
return $projectVersions
}
function New-DotNetReleaseContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
# The array wrapper is intentional: without it, one configured project can collapse to a string,
# and later indexing [0] would return only the first character of the path.
$projectFiles = @(Get-PluginPathListSetting -Plugins $Plugins -PropertyName "projectFiles" -BasePath $ScriptDir)
$artifactsDirectory = Get-PluginPathSetting -Plugins $Plugins -PropertyName "artifactsDir" -BasePath $ScriptDir
if ($projectFiles.Count -eq 0) {
Write-Error "No .NET project files configured in plugin settings. Add 'projectFiles' to a relevant plugin."
exit 1
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
Write-Error "No artifacts directory configured in plugin settings. Add 'artifactsDir' to a relevant plugin."
exit 1
}
$projectVersions = Get-DotNetProjectVersions -ProjectFiles $projectFiles
# The first configured project is treated as the canonical version source for the release.
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
ProjectFiles = $projectFiles
ArtifactsDirectory = $artifactsDirectory
Version = $version
}
}
Export-ModuleMember -Function Get-DotNetProjectPropertyValue, Get-DotNetProjectVersions, New-DotNetReleaseContext

View File

@ -1,165 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
$gitToolsModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "GitTools.psm1"
if (Test-Path $gitToolsModulePath -PathType Leaf) {
Import-Module $gitToolsModulePath -Force
}
}
if (-not (Get-Command Get-PluginStage -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force
}
}
if (-not (Get-Command New-DotNetReleaseContext -ErrorAction SilentlyContinue)) {
$dotNetProjectSupportModulePath = Join-Path $PSScriptRoot "DotNetProjectSupport.psm1"
if (Test-Path $dotNetProjectSupportModulePath -PathType Leaf) {
Import-Module $dotNetProjectSupportModulePath -Force
}
}
function Assert-WorkingTreeClean {
param(
[Parameter(Mandatory = $true)]
[bool]$IsReleaseBranch
)
$gitStatus = Get-GitStatusShort
if ($gitStatus) {
if ($IsReleaseBranch) {
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
Write-Log -Level "WARN" -Message "Uncommitted files:"
$gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
exit 1
}
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
return
}
Write-Log -Level "OK" -Message " Working directory is clean."
}
function Initialize-ReleaseStageContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$RemainingPlugins,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$ArtifactsDirectory,
[Parameter(Mandatory = $true)]
[string]$Version
)
Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
$remoteTagExists = Test-RemoteTagExists -Tag $SharedSettings.Tag -Remote "origin"
if (-not $remoteTagExists) {
Write-Log -Level "WARN" -Message " Tag $($SharedSettings.Tag) not found on remote. Pushing..."
Push-TagToRemote -Tag $SharedSettings.Tag -Remote "origin"
}
else {
Write-Log -Level "OK" -Message " Tag exists on remote."
}
if (-not $SharedSettings.PSObject.Properties['ReleaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.ReleaseDir)) {
$SharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $ArtifactsDirectory -Force
}
}
function New-EngineContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir,
[Parameter(Mandatory = $true)]
[string]$UtilsDir
)
$dotNetContext = New-DotNetReleaseContext -Plugins $Plugins -ScriptDir $ScriptDir
$currentBranch = Get-CurrentBranch
$releaseBranches = @(
$Plugins |
Where-Object { Test-IsPublishPlugin -Plugin $_ } |
ForEach-Object { Get-PluginBranches -Plugin $_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
)
$isReleaseBranch = $releaseBranches -contains $currentBranch
$isNonReleaseBranch = -not $isReleaseBranch
Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch
$version = $dotNetContext.Version
if ($isReleaseBranch) {
$tag = Get-CurrentCommitTag -Version $version
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
exit 1
}
$tagVersion = $Matches[1]
if ($tagVersion -ne $version) {
Write-Error "Tag version ($tagVersion) does not match the project version ($version)."
Write-Log -Level "WARN" -Message " Either update the tag or the project version."
exit 1
}
Write-Log -Level "OK" -Message " Tag found: $tag (matches project version)"
}
else {
$tag = "v$version"
Write-Log -Level "INFO" -Message " Using version from the package project (no tag required on non-release branches)."
}
return [pscustomobject]@{
ScriptDir = $ScriptDir
UtilsDir = $UtilsDir
CurrentBranch = $currentBranch
Version = $version
Tag = $tag
ProjectFiles = $dotNetContext.ProjectFiles
ArtifactsDirectory = $dotNetContext.ArtifactsDirectory
IsReleaseBranch = $isReleaseBranch
IsNonReleaseBranch = $isNonReleaseBranch
ReleaseBranches = $releaseBranches
NonReleaseBranches = @()
PublishCompleted = $false
}
}
function Get-PreferredReleaseBranch {
param(
[Parameter(Mandatory = $true)]
[psobject]$EngineContext
)
if ($EngineContext.ReleaseBranches.Count -gt 0) {
return $EngineContext.ReleaseBranches[0]
}
return "main"
}
Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch

View File

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

View File

@ -1,183 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Plugin-driven release engine.
.DESCRIPTION
This script is the orchestration layer for release automation.
It loads scriptsettings.json, evaluates the configured plugins in order,
builds shared execution context, and invokes each plugin's Invoke-Plugin
entrypoint with that plugin's own settings object plus runtime context.
The engine is intentionally generic:
- It does not embed release-provider-specific logic
- It preserves plugin execution order from scriptsettings.json
- It isolates plugin failures according to the stage/runtime policy
- It keeps shared orchestration helpers in dedicated support modules
.REQUIREMENTS
Tools (Required):
- Shared support modules required by the engine
- Any commands required by configured plugins or support helpers
.WORKFLOW
1. Load and normalize plugin configuration
2. Determine branch mode from configured plugin metadata
3. Validate repository state and resolve the release version
4. Build shared execution context
5. Execute plugins one by one in configured order
6. Initialize release-stage shared artifacts only when needed
7. Report completion summary
.USAGE
Configure plugin order and plugin settings in scriptsettings.json, then run:
pwsh -File .\Release-Package.ps1
.CONFIGURATION
All settings are stored in scriptsettings.json:
- Plugins: Ordered plugin definitions and plugin-specific settings
.NOTES
Plugin-specific behavior belongs in the plugin modules, not in this engine.
#>
# No parameters - behavior is controlled by configured plugin metadata:
# - non-release branches -> Run only the plugins allowed for those branches
# - release branches -> Require a matching tag and allow release-stage plugins
# Get the directory of the current script (for loading settings and relative paths)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
#region Import Modules
$utilsDir = Split-Path $scriptDir -Parent
# Import ScriptConfig module
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
Import-Module $scriptConfigModulePath -Force
# Import Logging module
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
}
Import-Module $loggingModulePath -Force
# Import PluginSupport module
$pluginSupportModulePath = Join-Path $scriptDir "PluginSupport.psm1"
if (-not (Test-Path $pluginSupportModulePath)) {
Write-Error "PluginSupport module not found at: $pluginSupportModulePath"
exit 1
}
Import-Module $pluginSupportModulePath -Force
# Import DotNetProjectSupport module
$dotNetProjectSupportModulePath = Join-Path $scriptDir "DotNetProjectSupport.psm1"
if (-not (Test-Path $dotNetProjectSupportModulePath)) {
Write-Error "DotNetProjectSupport module not found at: $dotNetProjectSupportModulePath"
exit 1
}
Import-Module $dotNetProjectSupportModulePath -Force
# Import EngineSupport module
$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1"
if (-not (Test-Path $engineSupportModulePath)) {
Write-Error "EngineSupport module not found at: $engineSupportModulePath"
exit 1
}
Import-Module $engineSupportModulePath -Force
#endregion
#region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
#endregion
#region Configuration
$pluginsDir = Join-Path $scriptDir "CorePlugins"
#endregion
#endregion
#region Main
Write-Log -Level "STEP" -Message "=================================================="
Write-Log -Level "STEP" -Message "RELEASE ENGINE"
Write-Log -Level "STEP" -Message "=================================================="
#region Preflight
$plugins = $configuredPlugins
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir
Write-Log -Level "OK" -Message "All pre-flight checks passed!"
$sharedPluginSettings = $engineContext
#endregion
#region Plugin Execution
$releaseStageInitialized = $false
if ($plugins.Count -eq 0) {
Write-Log -Level "WARN" -Message "No plugins configured in scriptsettings.json."
}
else {
for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
$plugin = $plugins[$pluginIndex]
$pluginStage = Get-PluginStage -Plugin $plugin
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -WriteLogs:$false) {
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.ArtifactsDirectory -Version $engineContext.Version
$releaseStageInitialized = $true
}
}
$continueOnError = $pluginStage -eq "Release"
Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -ContinueOnError:$continueOnError
}
}
if (-not $releaseStageInitialized) {
$noReleasePluginsLogLevel = if ($engineContext.IsNonReleaseBranch) { "INFO" } else { "WARN" }
Write-Log -Level $noReleasePluginsLogLevel -Message "No release plugins executed for branch '$($engineContext.CurrentBranch)'."
}
#endregion
#region Summary
Write-Log -Level "OK" -Message "=================================================="
if ($engineContext.IsNonReleaseBranch) {
Write-Log -Level "OK" -Message "NON-RELEASE RUN COMPLETE"
}
else {
Write-Log -Level "OK" -Message "RELEASE COMPLETE"
}
Write-Log -Level "OK" -Message "=================================================="
Write-Log -Level "INFO" -Message "Artifacts location: $($engineContext.ArtifactsDirectory)"
if ($engineContext.IsNonReleaseBranch) {
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
Write-Log -Level "INFO" -Message "To execute release-stage plugins, rerun from an allowed release branch such as '$preferredReleaseBranch'."
}
#endregion
#endregion

View File

@ -1,104 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
"description": "Configuration file for Release-Package.ps1 script.",
"Plugins": [
{
"Name": "DotNetTest",
"Stage": "Test",
"Enabled": true,
"project": "..\\..\\src\\MaksIT.UScheduler.Tests",
"resultsDir": "..\\..\\testResults"
},
{
"Name": "QualityGate",
"Stage": "QualityGate",
"Enabled": true,
"coverageThreshold": 0,
"failOnVulnerabilities": true
},
{
"Name": "DotNetPublish",
"Stage": "Build",
"Enabled": true,
"projectFiles": [
"..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
"..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj"
],
"artifactsDir": "..\\..\\release",
"bundleDir": "..\\..\\release\\bundle"
},
{
"Name": "BundleCustomization",
"Stage": "Build",
"Enabled": true,
"bundleDir": "..\\..\\release\\bundle",
"scriptsPath": "..\\..\\src\\Scripts",
"launcher": {
"enabled": true,
"fileName": "Start-ScheduleManager.bat",
"targetProject": "scheduleManager"
},
"projects": {
"scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj",
"uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj",
"scheduleManagerAppSettingsFile": "appsettings.json",
"uschedulerAppSettingsFile": "appsettings.json",
"scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\",
"uschedulerLogDir": "..\\..\\Logs",
"scriptsRelativeToExe": "..\\..\\Scripts"
}
},
{
"Name": "CreateArchive",
"Stage": "Build",
"Enabled": true,
"zipNamePattern": "maksit.uscheduler-{version}.zip"
},
{
"Name": "GitHub",
"Stage": "Release",
"branches": [
"main"
],
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/uscheduler",
"releaseNotesFile": "..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
},
{
"Name": "CleanupArtifacts",
"Stage": "Release",
"Enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
]
}
],
"_comments": {
"Plugins": {
"Name": "Plugin module file name in CorePlugins or CustomPlugins (for example, DotNetPublish -> CorePlugins/DotNetPublish.psm1).",
"Stage": "Execution phase. Supported values are Test, QualityGate, Build, and Release.",
"Enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"branches": "Plugin-specific allowed branches. Omit to allow any branch.",
"project": "DotNetTest plugin only. Path to the test project directory, relative to the script folder.",
"resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.",
"projectFiles": "DotNetPublish or another producer plugin can define the project files used for version discovery and artifact creation.",
"artifactsDir": "DotNetPublish or another producer plugin can define the artifacts output directory, relative to the script folder.",
"bundleDir": "DotNetPublish and BundleCustomization plugins can define the staged bundle directory, relative to the script folder.",
"coverageThreshold": "QualityGate plugin only. Coverage threshold percent (0 disables threshold check).",
"failOnVulnerabilities": "QualityGate plugin only. If true, fail when vulnerable packages are detected.",
"githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.",
"repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.",
"releaseNotesFile": "GitHub plugin (or another notes consumer plugin) can define the release notes source file, relative to the script folder.",
"releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.",
"zipNamePattern": "GitHub plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"scriptsPath": "BundleCustomization plugin only. Scripts folder copied into the staged bundle, relative to the script folder.",
"launcher": "BundleCustomization plugin only. Optional launcher batch file settings.",
"projects": "BundleCustomization plugin only. Project-specific appsettings rewrite settings."
}
}
}

View File

@ -1,202 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
PowerShell module for running tests with code coverage.
.DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests
with Coverlet code coverage collection and parsing results.
.NOTES
Author: MaksIT
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
Runs unit tests with code coverage and returns coverage metrics.
.PARAMETER TestProjectPath
Path to the test project directory.
.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.
.OUTPUTS
PSCustomObject with properties:
- Success: bool
- Error: string (if failed)
- LineRate: double
- BranchRate: double
- MethodRate: double
- TotalMethods: int
- CoveredMethods: int
- CoverageFile: string
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" }
#>
param(
[Parameter(Mandatory = $true)]
[string]$TestProjectPath,
[switch]$Silent,
[string]$ResultsDirectory,
[switch]$KeepResults
)
$ErrorActionPreference = "Stop"
# Resolve path
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
if (-not $TestProjectDir) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $TestProjectPath"
}
}
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $TestProjectDir "TestResults"
}
else {
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
}
# Clean previous results
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir"
}
# Run tests with coverage collection
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
} else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed with exit code $testExitCode"
}
}
}
finally {
Pop-Location
}
# Find the coverage file
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
if (-not $CoverageFile) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found"
}
}
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)"
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
}
# Parse coverage data from Cobertura XML
[xml]$coverageXml = Get-Content $CoverageFile.FullName
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
# Calculate method coverage from packages
$totalMethods = 0
$coveredMethods = 0
foreach ($package in $coverageXml.coverage.packages.package) {
foreach ($class in $package.classes.class) {
foreach ($method in $class.methods.method) {
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
}
}
}
}
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
# Cleanup unless KeepResults is specified
if (-not $KeepResults) {
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
}
# Return results
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = $CoverageFile.FullName
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage

View File

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

View File

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

View File

@ -0,0 +1,50 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Plugin-driven test and coverage engine entry script.
#>
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1')
Import-EngineModules -Engine Test
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
Write-Log -Level 'STEP' -Message '=================================================='
Write-Log -Level 'STEP' -Message 'TEST ENGINE'
Write-Log -Level 'STEP' -Message '=================================================='
$engineContext = New-EngineContext -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings
if ($configuredPlugins.Count -eq 0) {
Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.'
exit 0
}
$testHadPluginFailures = $false
foreach ($plugin in $configuredPlugins) {
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false
if (-not $pluginSucceeded) {
$testHadPluginFailures = $true
break
}
}
Write-Log -Level 'OK' -Message '=================================================='
if ($testHadPluginFailures) {
Write-Log -Level 'ERROR' -Message 'TEST RUN FAILED'
}
else {
Write-Log -Level 'OK' -Message 'TEST RUN COMPLETE'
}
Write-Log -Level 'OK' -Message '=================================================='
if ($testHadPluginFailures) {
exit 1
}

View File

View File

@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings",
"description": "Invoke-TestEngine.ps1 settings for MaksIT.UScheduler tests and coverage badges.",
"paths": {
"badgesDir": "..\\..\\..\\assets\\badges"
},
"plugins": [
{
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"project": "..\\..\\..\\src\\MaksIT.UScheduler.Tests"
},
{
"name": "QualityGate",
"stageLabel": "qualityGate",
"enabled": true,
"coverageThreshold": 0,
"scanVulnerabilities": false
},
{
"name": "CoverageBadges",
"stageLabel": "report",
"enabled": true,
"badgesDir": "..\\..\\..\\assets\\badges",
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
],
"colorThresholds": {
"brightgreen": 80,
"green": 60,
"yellowgreen": 40,
"yellow": 20,
"orange": 10,
"red": 0
}
}
]
}

View File

@ -0,0 +1,56 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Keep a Changelog header parsing and section extraction.
.DESCRIPTION
Supports only the standard Keep a Changelog version line:
## [1.0.0] - 2026-05-24
#>
function Get-ChangelogVersionHeaderPattern {
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-ChangelogNextVersionHeaderPattern {
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-LatestChangelogVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesContent
)
$match = [regex]::Match($ReleaseNotesContent, (Get-ChangelogVersionHeaderPattern))
if (-not $match.Success) {
return $null
}
return $match.Groups[1].Value
}
function Get-ChangelogReleaseNotesSection {
param(
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesContent,
[Parameter(Mandatory = $true)]
[string]$Version
)
$escapedVersion = [regex]::Escape($Version)
$nextHeaderPattern = Get-ChangelogNextVersionHeaderPattern
$headerPattern = "(?ms)^##\s+\[$escapedVersion\]\s*-\s*\d{4}-\d{2}-\d{2}.*?(?=$nextHeaderPattern|\Z)"
$match = [regex]::Match($ReleaseNotesContent, $headerPattern)
if (-not $match.Success) {
return $null
}
return $match.Value.Trim()
}
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection

View File

@ -0,0 +1,225 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Helpers to resolve engine semver and relative paths from plugin configuration.
.DESCRIPTION
Used by New-EngineContext and version plugins:
- DotNetReleaseVersion plugin -> projectFiles (.csproj <Version>)
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
#>
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 Resolve-RelativePaths {
param(
[Parameter(Mandatory = $true)]
[object]$Value,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
if ($null -eq $Value) {
return @()
}
$rawPaths = @()
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
$rawPaths += $Value
}
else {
$rawPaths += $Value
}
$resolved = @()
foreach ($p in $rawPaths) {
if ([string]::IsNullOrWhiteSpace([string]$p)) {
continue
}
$resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p)))
}
return @($resolved)
}
function Get-CsprojPropertyValue {
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-CsprojVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..."
$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-CsprojPropertyValue -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 Resolve-DotNetReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir)
if ($projectFiles.Count -eq 0) {
Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with <Version>)."
exit 1
}
$projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
version = $version
source = 'DotNetReleaseVersion'
}
}
function Resolve-NpmReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure an NpmReleaseVersion plugin in scriptSettings.json with packageJsonPath."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir)
if ($packageJsonPaths.Count -eq 0) {
Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath."
exit 1
}
$packageJsonPath = $packageJsonPaths[0]
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath"
exit 1
}
Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..."
$json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'."
exit 1
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver."
exit 1
}
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version"
return [pscustomobject]@{
version = $version
source = 'NpmReleaseVersion'
}
}
function Resolve-ReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false })
$npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false })
if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) {
Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both."
exit 1
}
if ($dotnetPlugin.Count -gt 0) {
return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
if ($npmPlugin.Count -gt 0) {
return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json."
exit 1
}
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion

View File

@ -0,0 +1,35 @@
#requires -Version 7.0
#requires -PSEdition Core
function Import-EngineModules {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Release', 'Test')]
[string]$Engine
)
$engineModuleDir = $PSScriptRoot
$modulesDir = Split-Path $engineModuleDir -Parent
$supportModules = @(
(Join-Path $modulesDir 'ScriptConfig.psm1'),
(Join-Path $modulesDir 'Logging.psm1'),
(Join-Path $engineModuleDir 'PluginSupport.psm1'),
(Join-Path $engineModuleDir 'EngineContext.psm1')
)
if ($Engine -eq 'Release') {
$supportModules += (Join-Path $engineModuleDir 'ReleaseSupport.psm1')
}
else {
$supportModules += (Join-Path $engineModuleDir 'TestSupport.psm1')
}
foreach ($modulePath in $supportModules) {
if (-not (Test-Path $modulePath -PathType Leaf)) {
throw "Required module not found at: $modulePath"
}
Import-Module $modulePath -Force
}
}

View File

@ -1,8 +1,16 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-RepoUtilsSrcDirectory {
return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
}
function Get-RepoUtilsModulesDirectory {
return Split-Path $PSScriptRoot -Parent
}
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
$loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
@ -21,11 +29,14 @@ function Import-PluginDependency {
return
}
$moduleRoot = Split-Path $PSScriptRoot -Parent
$modulePath = Join-Path $moduleRoot "$ModuleName.psm1"
$modulesDir = Get-RepoUtilsModulesDirectory
$engineModuleDir = $PSScriptRoot
$modulePath = Join-Path $modulesDir "$ModuleName.psm1"
if (-not (Test-Path $modulePath -PathType Leaf)) {
$modulePath = Join-Path $engineModuleDir "$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
}
@ -40,30 +51,28 @@ function Get-ConfiguredPlugins {
[psobject]$Settings
)
if (-not $Settings.PSObject.Properties['Plugins'] -or $null -eq $Settings.Plugins) {
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)
if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
return @($Settings.plugins)
}
return @($Settings.Plugins)
return @($Settings.plugins)
}
function Get-PluginStage {
function Get-PluginStageLabel {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['Stage'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.Stage)) {
return "Release"
if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
return 'release'
}
return [string]$Plugin.Stage
return [string]$Plugin.stageLabel
}
function Get-PluginBranches {
@ -76,7 +85,6 @@ function Get-PluginBranches {
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($_) })
}
@ -88,17 +96,38 @@ function Get-PluginBranches {
return @([string]$Plugin.branches)
}
function Test-PluginAllowedOnBranch {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
$allowedBranches = Get-PluginBranches -Plugin $Plugin
if ($allowedBranches.Count -eq 0) {
return $true
}
if ($allowedBranches -contains '*') {
return $true
}
return $allowedBranches -contains $CurrentBranch
}
function Test-IsPublishPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.Name)) {
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
return $false
}
return @('GitHub', 'NuGet') -contains ([string]$Plugin.Name)
return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name)
}
function Get-PluginSettingValue {
@ -111,7 +140,7 @@ function Get-PluginSettingValue {
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
@ -153,7 +182,6 @@ function Get-PluginPathListSetting {
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
}
@ -170,7 +198,6 @@ function Get-PluginPathListSetting {
$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)
}
@ -204,16 +231,15 @@ function Get-ArchiveNamePattern {
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.Enabled) {
if (-not $plugin.enabled) {
continue
}
$allowedBranches = Get-PluginBranches -Plugin $plugin
if ($allowedBranches.Count -gt 0 -and -not ($allowedBranches -contains $CurrentBranch)) {
if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
continue
}
@ -231,13 +257,17 @@ function Resolve-PluginModulePath {
$Plugin,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory
[string]$EngineDirectory
)
$pluginFileName = "{0}.psm1" -f $Plugin.Name
$srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent
$pluginsRoot = Join-Path $srcDir "plugins"
$pluginFileName = "{0}.psm1" -f $Plugin.name
$candidatePaths = @(
(Join-Path $PluginsDirectory $pluginFileName),
(Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName)
(Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "Platform") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "DotNet") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "Npm") $pluginFileName)
)
foreach ($candidatePath in $candidatePaths) {
@ -258,44 +288,27 @@ function Test-PluginRunnable {
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.Name)) {
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin entry with no Name."
Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
}
return $false
}
if (-not $Plugin.Enabled) {
if (-not $Plugin.enabled) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' (disabled)."
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
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
if ($WriteLogs) {
Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
@ -320,8 +333,7 @@ function New-PluginInvocationSettings {
$properties[$property.Name] = $property.Value
}
# Plugins receive their own config plus a shared Context object that carries runtime artifacts.
$properties['Context'] = $SharedSettings
$properties['context'] = $SharedSettings
return [pscustomobject]$properties
}
@ -334,35 +346,41 @@ function Invoke-ConfiguredPlugin {
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$ContinueOnError = $true
[bool]$ContinueOnError = $false
)
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -PluginsDirectory $PluginsDirectory -WriteLogs:$true)) {
return
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
if ($Plugin.enabled) {
return $false
}
return $true
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.Name)'..."
if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)."
return $true
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
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."
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
return $true
}
catch {
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.Name)' failed: $($_.Exception.Message)"
if (-not $ContinueOnError) {
exit 1
}
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
return $false
}
}
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
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -0,0 +1,151 @@
#requires -Version 7.0
#requires -PSEdition Core
$modulesDir = Split-Path $PSScriptRoot -Parent
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
$gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1"
if (Test-Path $gitToolsModulePath -PathType Leaf) {
Import-Module $gitToolsModulePath -Force
}
}
if (-not (Get-Command Get-PluginStageLabel -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 Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) {
$engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1"
if (Test-Path $engineContextModulePath -PathType Leaf) {
Import-Module $engineContextModulePath -Force
}
}
function Assert-WorkingTreeClean {
$gitStatus = Get-GitStatusShort
if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) {
Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)."
foreach ($line in @([string]$gitStatus -split "`r?`n")) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
Write-Log -Level "WARN" -Message " $line"
}
}
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
)
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]$SrcDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
)
$resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
$version = $resolvedVersion.version
$versionSource = $resolvedVersion.source
$releaseRelative = '..\..\..\release'
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative))
$currentBranch = Get-CurrentBranch
$releaseBranches = @()
foreach ($p in $Plugins) {
if (-not $p.enabled) { continue }
if ([string]$p.name -ne 'ReleasePublishGuard') { continue }
foreach ($b in (Get-PluginBranches -Plugin $p)) {
$releaseBranches += $b
}
}
$releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
if ($releaseBranches.Count -eq 0) {
foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) {
if (-not $p.enabled) { continue }
foreach ($b in (Get-PluginBranches -Plugin $p)) {
$releaseBranches += $b
}
}
$releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
}
if ($releaseBranches.Count -eq 0) {
$releaseBranches = @('main')
}
$isReleaseBranch = $releaseBranches -contains $currentBranch
$isNonReleaseBranch = -not $isReleaseBranch
Assert-WorkingTreeClean
$tag = "v$version"
Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
return [pscustomobject]@{
scriptDir = $ScriptDir
srcDir = $SrcDir
utilsDir = $SrcDir
currentBranch = $currentBranch
version = $version
tag = $tag
artifactsDirectory = $artifactsDirectory
isReleaseBranch = $isReleaseBranch
isNonReleaseBranch = $isNonReleaseBranch
releaseBranches = $releaseBranches
publishCompleted = $false
skipPublishPlugins = $false
}
}
function Get-PreferredReleaseBranch {
param(
[Parameter(Mandatory = $true)]
[psobject]$EngineContext
)
if ($EngineContext.releaseBranches.Count -gt 0) {
return $EngineContext.releaseBranches[0]
}
return "main"
}
Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch

View File

@ -0,0 +1,38 @@
#requires -Version 7.0
#requires -PSEdition Core
$modulesDir = Split-Path $PSScriptRoot -Parent
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function New-EngineContext {
param(
[Parameter(Mandatory = $true)]
[string]$ScriptDir,
[Parameter(Mandatory = $true)]
[string]$SrcDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
)
$badgesDir = $null
if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) {
$badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir)))
}
return [pscustomobject]@{
scriptDir = $ScriptDir
srcDir = $SrcDir
utilsDir = $SrcDir
badgesDir = $badgesDir
}
}
Export-ModuleMember -Function New-EngineContext

View File

@ -76,7 +76,7 @@ function Invoke-GitInternal {
# Used by:
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Resolve and print the current branch name.
function Get-CurrentBranch {
@ -89,7 +89,7 @@ function Get-CurrentBranch {
# Used by:
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Return `git status --short` output for pending-change checks.
function Get-GitStatusShort {
@ -112,7 +112,7 @@ function Get-CurrentCommitTag {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Get all tag names pointing at HEAD.
function Get-HeadTags {
@ -144,7 +144,7 @@ function Test-RemoteTagExists {
# Used by:
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Push tag to remote (optionally with `--force`).
function Push-TagToRemote {
@ -169,7 +169,7 @@ function Push-TagToRemote {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Push branch to remote (optionally with `--force`).
function Push-BranchToRemote {
@ -194,7 +194,7 @@ function Push-BranchToRemote {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Get HEAD commit hash.
function Get-HeadCommitHash {
@ -208,7 +208,7 @@ function Get-HeadCommitHash {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Get HEAD commit subject line.
function Get-HeadCommitMessage {
@ -216,7 +216,7 @@ function Get-HeadCommitMessage {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Stage all changes (tracked, untracked, deletions).
function Add-AllChanges {
@ -224,7 +224,7 @@ function Add-AllChanges {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Amend HEAD commit and keep existing commit message.
function Update-HeadCommitNoEdit {
@ -232,7 +232,7 @@ function Update-HeadCommitNoEdit {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Delete local tag.
function Remove-LocalTag {
@ -245,7 +245,7 @@ function Remove-LocalTag {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Create local tag.
function New-LocalTag {
@ -258,7 +258,7 @@ function New-LocalTag {
}
# Used by:
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
# Purpose:
# - Get HEAD one-line commit info.
function Get-HeadCommitOneLine {

View File

@ -7,7 +7,7 @@ function Get-ScriptSettings {
[string]$ScriptDir,
[Parameter(Mandatory = $false)]
[string]$SettingsFileName = "scriptsettings.json"
[string]$SettingsFileName = "scriptSettings.json"
)
$settingsPath = Join-Path $ScriptDir $SettingsFileName

View File

@ -0,0 +1,392 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
PowerShell module for running tests with code coverage.
.DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests
with Coverlet code coverage collection and parsing results.
.NOTES
Author: MaksIT
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
Runs unit tests with code coverage and returns coverage metrics.
.PARAMETER TestProjectPath
One or more paths to test project directories (or .csproj files). Each project
is tested in order; coverage metrics are aggregated across all Cobertura outputs.
.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.
.OUTPUTS
PSCustomObject with properties:
- Success: bool
- Error: string (if failed)
- LineRate: double
- BranchRate: double
- MethodRate: double
- TotalMethods: int
- CoveredMethods: int
- CoverageFile: string (ReportGenerator reports arg: one file, or semicolon-separated)
- CoverageFiles: string[] (individual Cobertura paths)
- ResultsDirectory: string (absolute folder used for dotnet test output; may be removed after run unless -KeepResults)
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" }
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath @(".\ProjA.Tests", ".\ProjB.Tests")
#>
param(
[Parameter(Mandatory = $true)]
[string[]]$TestProjectPath,
[switch]$Silent,
[string]$ResultsDirectory,
[switch]$KeepResults
)
$ErrorActionPreference = "Stop"
# Normalize to a non-empty list of absolute working directories (folder containing the test project).
$resolvedProjectDirs = [System.Collections.Generic.List[string]]::new()
foreach ($raw in $TestProjectPath) {
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$full = [System.IO.Path]::GetFullPath($raw.Trim())
if (-not (Test-Path $full)) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $raw"
}
}
$item = Get-Item -LiteralPath $full
$dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName }
if ($resolvedProjectDirs -notcontains $dir) {
[void]$resolvedProjectDirs.Add($dir)
}
}
if ($resolvedProjectDirs.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No valid test project paths were provided."
}
}
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults"
}
else {
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
}
# Clean previous results once (shared output for all test runs).
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
}
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
}
else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode"
}
}
}
finally {
Pop-Location
}
}
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found under: $ResultsDir"
}
}
if (-not $Silent) {
foreach ($cf in $coverageFiles) {
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($cf.FullName)"
}
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
}
# Aggregate line/branch from Cobertura counters; methods by walking all files.
$linesCoveredTotal = 0L
$linesValidTotal = 0L
$branchesCoveredTotal = 0L
$branchesValidTotal = 0L
$totalMethods = 0
$coveredMethods = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$root = $coverageXml.coverage
$lcAttr = $root.'lines-covered'
$lvAttr = $root.'lines-valid'
if ($null -ne $lcAttr -and $null -ne $lvAttr -and [long]$lvAttr -gt 0) {
$linesCoveredTotal += [long]$lcAttr
$linesValidTotal += [long]$lvAttr
}
$bcAttr = $root.'branches-covered'
$bvAttr = $root.'branches-valid'
if ($null -ne $bcAttr -and $null -ne $bvAttr -and [long]$bvAttr -gt 0) {
$branchesCoveredTotal += [long]$bcAttr
$branchesValidTotal += [long]$bvAttr
}
foreach ($package in @($root.packages.package)) {
foreach ($class in @($package.classes.class)) {
$methodNodes = $class.methods
if ($null -eq $methodNodes) { continue }
foreach ($method in @($methodNodes.method)) {
if ($null -eq $method) { continue }
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
}
}
}
}
}
if ($linesValidTotal -gt 0) {
$lineRate = [math]::Round(($linesCoveredTotal / $linesValidTotal) * 100, 1)
}
else {
# Fallback: average of per-file line-rate when counters are missing (older Cobertura).
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'line-rate'
$n++
}
$lineRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
if ($branchesValidTotal -gt 0) {
$branchRate = [math]::Round(($branchesCoveredTotal / $branchesValidTotal) * 100, 1)
}
else {
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'branch-rate'
$n++
}
$branchRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
$coveragePaths = @($coverageFiles | ForEach-Object { $_.FullName })
$coverageFileReportArg = $coveragePaths -join ";"
$resultsDirectoryFull = [System.IO.Path]::GetFullPath($ResultsDir)
# Cleanup unless KeepResults is specified
if (-not $KeepResults) {
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
}
# Return results
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = $coverageFileReportArg
CoverageFiles = $coveragePaths
ResultsDirectory = $resultsDirectoryFull
}
}
function Invoke-NpmJestTestsWithCoverage {
<#
.SYNOPSIS
Runs npm/Jest tests with coverage and returns normalized metrics.
.PARAMETER WorkspaceRoot
npm workspace root (folder containing package.json and jest.config).
.PARAMETER TestScript
npm script name to run (default: test). Coverage flags are appended via `--`.
.PARAMETER CoverageDirectory
Relative path under WorkspaceRoot where Jest writes coverage output.
.PARAMETER Silent
Suppress console output from npm.
.OUTPUTS
Same metric shape as Invoke-TestsWithCoverage, plus CoverageSummaryFile when available.
#>
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[string]$TestScript = 'test',
[string]$CoverageDirectory = 'coverage',
[switch]$Silent
)
$ErrorActionPreference = 'Stop'
$workspaceFull = [System.IO.Path]::GetFullPath($WorkspaceRoot)
if (-not (Test-Path (Join-Path $workspaceFull 'package.json') -PathType Leaf)) {
return [PSCustomObject]@{
Success = $false
Error = "package.json not found in workspace root: $workspaceFull"
}
}
if (-not $Silent) {
Write-TestRunnerLogInternal -Level 'STEP' -Message 'Running npm/Jest tests with coverage...'
Write-TestRunnerLogInternal -Level 'INFO' -Message "Workspace: $workspaceFull"
}
Push-Location $workspaceFull
try {
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
if ($Silent) {
$null = & npm @npmArgs 2>&1
}
else {
& npm @npmArgs
}
if ($LASTEXITCODE -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "npm run $TestScript failed with exit code $LASTEXITCODE"
}
}
}
finally {
Pop-Location
}
$summaryPath = Join-Path $workspaceFull (Join-Path $CoverageDirectory 'coverage-summary.json')
if (-not (Test-Path $summaryPath -PathType Leaf)) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary not found at: $summaryPath"
}
}
$summaryJson = Get-Content -LiteralPath $summaryPath -Raw -Encoding UTF8 | ConvertFrom-Json
$total = $summaryJson.total
if ($null -eq $total) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary is missing 'total' metrics in: $summaryPath"
}
}
$lineRate = [math]::Round([double]$total.lines.pct, 1)
$branchRate = [math]::Round([double]$total.branches.pct, 1)
$methodRate = [math]::Round([double]$total.functions.pct, 1)
$totalMethods = [int]$total.functions.total
$coveredMethods = [int]$total.functions.covered
$resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $workspaceFull $CoverageDirectory))
if (-not $Silent) {
Write-TestRunnerLogInternal -Level 'OK' -Message "Coverage summary: $summaryPath"
}
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageSummaryFile = $summaryPath
ResultsDirectory = $resultsDirectory
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage

View File

@ -3,16 +3,17 @@
<#
.SYNOPSIS
Cleanup plugin for removing generated artifacts after pipeline completion.
.NET artifact cleanup plugin — remove NuGet build outputs after release.
.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.
Removes files from the configured artifacts directory using glob patterns.
Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the
end of the Release stage after DotNetCreateArchive or publish plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
@ -69,13 +70,13 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$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."
throw "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {

View File

@ -3,16 +3,17 @@
<#
.SYNOPSIS
Creates a release zip from prepared build artifacts.
.NET release archive plugin — zip from NuGet pack/publish artifacts.
.DESCRIPTION
This plugin compresses the release artifact inputs prepared by an earlier
producer plugin (for example DotNetPack or DotNetPublish) into a zip file
This plugin compresses .NET release artifact inputs prepared by an earlier
DotNet plugin (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"
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
@ -27,27 +28,27 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$sharedSettings = $Settings.context
$artifactsDirectory = $sharedSettings.artifactsDirectory
$version = $sharedSettings.version
$archiveInputs = @()
if ($sharedSettings.PSObject.Properties['ReleaseArchiveInputs'] -and $sharedSettings.ReleaseArchiveInputs) {
$archiveInputs = @($sharedSettings.ReleaseArchiveInputs)
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
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."
throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first."
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "CreateArchive plugin requires an artifacts directory in the shared context."
throw "DotNetCreateArchive plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
@ -78,16 +79,16 @@ function Invoke-Plugin {
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['packageFile'] -and $sharedSettings.packageFile) {
$releaseAssetPaths += $sharedSettings.packageFile.FullName
}
if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.SymbolsPackageFile.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
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchivePath -NotePropertyValue $zipPath -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,245 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET Docker publish plugin — build and push container images for .NET apps.
.DESCRIPTION
Logs in with credentials from a Base64-encoded username:password environment variable,
builds each configured image once, then tags and pushes: bare semver from DotNetReleaseVersion
(e.g. 3.3.4), v-prefixed alias (v3.3.4) when different, optional exact shared.tag if it differs,
and optional latest.
Release image tags align with shared.version (same bare semver as Helm chart/OCI when used together); not from Chart.yaml.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-RegistryCredentialsFromEnv {
param(
[Parameter(Mandatory = $true)]
[string]$EnvVarName
)
$raw = [Environment]::GetEnvironmentVariable($EnvVarName)
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$EnvVarName' is not set."
}
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
}
catch {
throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$EnvVarName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
function Set-EnvVersionValue {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string]$Version
)
$content = Get-Content -LiteralPath $FilePath -Raw
if ($content -match '(?m)^\s*VITE_APP_VERSION\s*=') {
$content = $content -replace '(?m)^\s*VITE_APP_VERSION\s*=.*$', "VITE_APP_VERSION=$Version"
}
else {
$separator = if ($content -match "(\r?\n)$") { '' } else { [Environment]::NewLine }
$content = "$content${separator}VITE_APP_VERSION=$Version"
}
Set-Content -LiteralPath $FilePath -Value $content -NoNewline
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command docker
if ([string]::IsNullOrWhiteSpace($pluginSettings.registryUrl)) {
throw "DotNetDockerPush plugin requires 'registryUrl' (registry hostname, no scheme)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) {
throw "DotNetDockerPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.projectName)) {
throw "DotNetDockerPush plugin requires 'projectName' (image path segment after registry)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.contextPath)) {
throw "DotNetDockerPush plugin requires 'contextPath' (Docker build context, relative to engines/release folder)."
}
if (-not $pluginSettings.images -or @($pluginSettings.images).Count -eq 0) {
throw "DotNetDockerPush plugin requires a non-empty 'images' array with 'service' and 'dockerfile' per entry."
}
$scriptDir = $shared.scriptDir
$contextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.contextPath)))
if (-not (Test-Path $contextPath -PathType Container)) {
throw "Docker context directory not found: $contextPath"
}
$registryUrl = [string]$pluginSettings.registryUrl.TrimEnd('/')
$creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar)
$bareVersion = $null
if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) {
$bareVersion = ([string]$shared.version).Trim() -replace '^[vV]', ''
}
if ([string]::IsNullOrWhiteSpace($bareVersion) -and $shared.PSObject.Properties.Name -contains 'tag') {
$bareVersion = ([string]$shared.tag).Trim() -replace '^[vV]', ''
}
if ([string]::IsNullOrWhiteSpace($bareVersion)) {
throw "DotNetDockerPush: could not derive version tag (need shared.version from DotNetReleaseVersion or shared.tag)."
}
$imageTags = New-Object System.Collections.Generic.List[string]
function Add-ImageTag([System.Collections.Generic.List[string]]$List, [string]$Tag) {
if ([string]::IsNullOrWhiteSpace($Tag)) { return }
if (-not $List.Contains($Tag)) { [void]$List.Add($Tag) }
}
Add-ImageTag $imageTags $bareVersion
Add-ImageTag $imageTags "v$bareVersion"
if ($shared.PSObject.Properties.Name -contains 'tag') {
Add-ImageTag $imageTags ([string]$shared.tag).Trim()
}
$pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $true }
if ($pushLatest) {
Add-ImageTag $imageTags 'latest'
}
Write-Log -Level "STEP" -Message "Docker login to $registryUrl..."
$loginResult = $creds.Password | docker login $registryUrl -u $creds.User --password-stdin 2>&1
if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch 'Login Succeeded')) {
throw "Docker login failed for ${registryUrl}: $loginResult"
}
try {
foreach ($img in @($pluginSettings.images)) {
if ($null -eq $img.service -or $null -eq $img.dockerfile) {
throw "Each images[] entry must define 'service' and 'dockerfile'."
}
$service = [string]$img.service
$dockerfileRel = [string]$img.dockerfile
$imgContextPath = $contextPath
if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) {
$imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath)))
if (-not (Test-Path $imgContextPath -PathType Container)) {
throw "Docker context directory not found for image '$service': $imgContextPath"
}
}
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel))
if (-not (Test-Path $dockerfilePath -PathType Leaf)) {
throw "Dockerfile not found: $dockerfilePath"
}
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
$versionEnvFiles = @()
if ($img.PSObject.Properties.Name -contains 'versionEnvFiles' -and $null -ne $img.versionEnvFiles) {
foreach ($relativeEnvFile in @($img.versionEnvFiles)) {
if ([string]::IsNullOrWhiteSpace([string]$relativeEnvFile)) {
continue
}
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath ([string]$relativeEnvFile)))
if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) {
throw "Configured versionEnvFiles entry not found: $envFilePath"
}
$backupPath = "$envFilePath.repoutils.bak"
Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force
$versionEnvFiles += [pscustomobject]@{
FilePath = $envFilePath
BackupPath = $backupPath
}
}
}
try {
foreach ($envFile in $versionEnvFiles) {
Write-Log -Level "INFO" -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)"
Set-EnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion
}
$primaryRef = "${baseName}:$($imageTags[0])"
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
docker build -t $primaryRef -f $dockerfilePath $imgContextPath
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed for $primaryRef"
}
Write-Log -Level "STEP" -Message "Pushing $primaryRef ..."
docker push $primaryRef
if ($LASTEXITCODE -ne 0) {
throw "Docker push failed for $primaryRef"
}
for ($ti = 1; $ti -lt $imageTags.Count; $ti++) {
$aliasRef = "${baseName}:$($imageTags[$ti])"
Write-Log -Level "STEP" -Message "Tagging and pushing $aliasRef ..."
docker tag $primaryRef $aliasRef
if ($LASTEXITCODE -ne 0) {
throw "Docker tag failed: $primaryRef -> $aliasRef"
}
docker push $aliasRef
if ($LASTEXITCODE -ne 0) {
throw "Docker push failed for $aliasRef"
}
}
}
finally {
foreach ($envFile in $versionEnvFiles) {
if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) {
Move-Item -LiteralPath $envFile.BackupPath -Destination $envFile.FilePath -Force
}
}
foreach ($envFile in $versionEnvFiles) {
if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) {
Remove-Item -LiteralPath $envFile.BackupPath -Force -ErrorAction SilentlyContinue
}
}
}
}
}
finally {
docker logout $registryUrl 2>&1 | Out-Null
}
Write-Log -Level "OK" -Message " Docker push completed."
$shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,181 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET Helm publish plugin — package and push charts versioned from DotNetReleaseVersion.
.DESCRIPTION
The chart in the repo should keep placeholder version and appVersion (e.g. 0.0.0); this plugin
overwrites them with the bare semver from shared context (DotNetReleaseVersion / shared.version,
e.g. 3.3.4 — no leading v), falling back to stripping v/V from shared.tag if version is missing,
then runs helm package and helm push, then restores Chart.yaml.
Optional pushLatest (default false when omitted): when true, after the versioned push, copies the chart
to a :latest tag in the same OCI repository using the oras CLI (https://oras.land). Requires oras on PATH.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-RegistryCredentialsFromEnv {
param(
[Parameter(Mandatory = $true)]
[string]$EnvVarName
)
$raw = [Environment]::GetEnvironmentVariable($EnvVarName)
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$EnvVarName' is not set."
}
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
}
catch {
throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$EnvVarName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command helm
$pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $false }
if ([string]::IsNullOrWhiteSpace($pluginSettings.chartPath)) {
throw "DotNetHelmPush plugin requires 'chartPath' (chart directory, relative to engines/release folder)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.ociRepository)) {
throw "DotNetHelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) {
throw "DotNetHelmPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)."
}
$scriptDir = $shared.ScriptDir
$chartDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.chartPath)))
$chartYaml = Join-Path $chartDir 'Chart.yaml'
if (-not (Test-Path $chartYaml -PathType Leaf)) {
throw "Chart.yaml not found at: $chartYaml"
}
$chartVersion = $null
if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) {
$chartVersion = ([string]$shared.version).Trim() -replace '^[vV]', ''
}
if ([string]::IsNullOrWhiteSpace($chartVersion) -and $shared.PSObject.Properties.Name -contains 'tag') {
$chartVersion = ([string]$shared.tag).Trim() -replace '^[vV]', ''
}
if ([string]::IsNullOrWhiteSpace($chartVersion)) {
throw "Could not derive chart version: need shared.version (DotNetReleaseVersion) or shared.tag (e.g. v3.3.4)."
}
$creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar)
$ociRepository = [string]$pluginSettings.ociRepository.TrimEnd('/')
$chartNameLine = Select-String -LiteralPath $chartYaml -Pattern '^\s*name:\s*(.+)\s*$' | Select-Object -First 1
if (-not $chartNameLine -or $chartNameLine.Matches.Count -lt 1) {
throw "Could not read chart name from Chart.yaml."
}
$chartName = $chartNameLine.Matches[0].Groups[1].Value.Trim()
$backupPath = "$chartYaml.bak"
Copy-Item -LiteralPath $chartYaml -Destination $backupPath -Force
try {
$content = Get-Content -LiteralPath $chartYaml -Raw
$content = $content `
-replace '(?m)^\s*version:\s*.*$', "version: $chartVersion" `
-replace '(?m)^\s*appVersion:\s*.*$', "appVersion: `"$chartVersion`""
Set-Content -LiteralPath $chartYaml -Value $content
Write-Log -Level "STEP" -Message "Linting Helm chart at $chartDir ..."
helm lint $chartDir
if ($LASTEXITCODE -ne 0) {
throw "helm lint failed."
}
$packageDest = $scriptDir
Write-Log -Level "STEP" -Message "Packaging Helm chart..."
$packageOutput = helm package $chartDir --destination $packageDest 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
throw "helm package failed. Output: $packageOutput"
}
$chartPackage = Join-Path $packageDest "$chartName-$chartVersion.tgz"
if (-not (Test-Path -LiteralPath $chartPackage -PathType Leaf)) {
throw "Expected chart package not found: $chartPackage (helm output: $packageOutput)"
}
Write-Log -Level "STEP" -Message "Pushing $chartPackage to $ociRepository ..."
helm push $chartPackage $ociRepository --username $creds.User --password $creds.Password
if ($LASTEXITCODE -ne 0) {
throw "helm push failed."
}
if ($pushLatest) {
Assert-Command oras
if ($ociRepository -notmatch '^oci://([^/]+)') {
throw "Could not parse registry host from ociRepository: $ociRepository"
}
$registryHost = $Matches[1]
$baseRef = "$($ociRepository.TrimEnd('/'))/$chartName"
$srcRef = "${baseRef}:$chartVersion"
$dstRef = "${baseRef}:latest"
Write-Log -Level "STEP" -Message "Tagging chart as latest (oras copy)..."
Write-Log -Level "INFO" -Message " $srcRef -> $dstRef"
$loginOut = $creds.Password | & oras login $registryHost -u $creds.User --password-stdin 2>&1
if ($LASTEXITCODE -ne 0) {
throw "oras login failed for ${registryHost}: $loginOut"
}
$copyOut = & oras copy $srcRef $dstRef 2>&1
if ($LASTEXITCODE -ne 0) {
throw "oras copy failed: $copyOut"
}
& oras logout $registryHost 2>&1 | Out-Null
Write-Log -Level "OK" -Message " Chart latest tag pushed."
}
Remove-Item -LiteralPath $chartPackage -Force -ErrorAction SilentlyContinue
Write-Log -Level "OK" -Message " Helm chart push completed."
}
finally {
if (Test-Path -LiteralPath $backupPath -PathType Leaf) {
Move-Item -LiteralPath $backupPath -Destination $chartYaml -Force
}
}
$shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -3,7 +3,7 @@
<#
.SYNOPSIS
NuGet publish plugin.
.NET NuGet publish plugin.
.DESCRIPTION
This plugin publishes the package artifact from shared runtime
@ -11,7 +11,8 @@
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
@ -27,18 +28,18 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$sharedSettings = $Settings.context
$nugetApiKeyEnvVar = $pluginSettings.nugetApiKey
$packageFile = $sharedSettings.PackageFile
$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."
throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet."
}
if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) {
throw "NuGet plugin requires 'nugetApiKey' in scriptsettings.json."
throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json."
}
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
@ -53,15 +54,18 @@ function Invoke-Plugin {
$pluginSettings.source
}
Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..."
dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
if ($LASTEXITCODE -ne 0) {
throw "Failed to push the package to NuGet."
throw "Failed to push the package to NuGet feed."
}
Write-Log -Level "OK" -Message " NuGet push completed."
$sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force
$sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,128 @@
#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)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = $sharedSettings.version
if ($Settings.PSObject.Properties['projectFiles'] -and $null -ne $Settings.projectFiles) {
$projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $scriptDir)
}
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
$projectFiles = @($sharedSettings.projectFiles)
}
else {
$projectFiles = @()
}
if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir)))
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
$packageProjectPath = $null
$releaseArchiveInputs = @()
Assert-Command dotnet
if ($projectFiles.Count -eq 0) {
throw "DotNetPack plugin requires projectFiles in plugin settings or projectFiles on shared context."
}
$outputDir = $artifactsDirectory
if (!(Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# First path in the configured project list is the pack target.
$packageProjectPath = (@($projectFiles))[0]
Write-Log -Level "STEP" -Message "Packing NuGet package..."
$dotnetPackArguments = @(
'pack', $packageProjectPath, '-c', 'Release', '-o', $outputDir, '--nologo',
'-p:IncludeSymbols=true', '-p:SymbolPackageFormat=snupkg'
)
& dotnet @dotnetPackArguments
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 = $null
$newestNupkgWrite = [datetime]::MinValue
$nupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.nupkg"
foreach ($candidate in $nupkgCandidates) {
if (($candidate.Name -like "*$version*.nupkg") -and ($candidate.Name -notlike "*.symbols.nupkg") -and ($candidate.Name -notlike "*.snupkg")) {
if ($candidate.LastWriteTime -gt $newestNupkgWrite) {
$newestNupkgWrite = $candidate.LastWriteTime
$packageFile = $candidate
}
}
}
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 = $null
$newestSnupkgWrite = [datetime]::MinValue
$snupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.snupkg"
foreach ($candidate in $snupkgCandidates) {
if ($candidate.Name -like "*$version*.snupkg") {
if ($candidate.LastWriteTime -gt $newestSnupkgWrite) {
$newestSnupkgWrite = $candidate.LastWriteTime
$symbolsPackageFile = $candidate
}
}
}
if ($symbolsPackageFile) {
Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)"
$releaseArchiveInputs += $symbolsPackageFile.FullName
}
else {
Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $packageFile -Force
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -12,7 +12,8 @@
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
@ -27,14 +28,14 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$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) {
if (-not $sharedSettings.PSObject.Properties['projectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPublish plugin requires project files in the shared context."
}
@ -63,9 +64,9 @@ function Invoke-Plugin {
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
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $null -Force
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $null -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue @($publishDir) -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,41 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Loads release version into shared context.
.DESCRIPTION
Dedicated version-loading plugin. It reads .csproj version via
EngineContext helpers and writes Version into the shared runtime context.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-DotNetReleaseVersion"
$shared = $Settings.context
$resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir
$projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir)
$shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force
$shared | Add-Member -NotePropertyName projectFiles -NotePropertyValue $projectFiles -Force
Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $($shared.version)"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,99 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET test plugin for executing automated tests.
.DESCRIPTION
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
via TestRunner, then publishes metrics on the shared engine context for any later
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
those keys generically (not tied to this plugin by name). Cobertura files are removed
after parsing unless TestRunner gains KeepResults.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# 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
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.scriptDir
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
if ($pluginSettings.PSObject.Properties.Name -contains 'projects' -and $pluginSettings.projects) {
foreach ($rel in @($pluginSettings.projects)) {
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) {
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project)))
}
if ($testProjectPaths.Count -eq 0) {
throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json."
}
$testResultsDir = $null
if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting))
}
elseif ($testProjectPaths.Count -gt 1) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir "TestResults"))
}
Write-Log -Level "STEP" -Message "Running tests..."
# Build a splatted hashtable so optional arguments can be added without duplicating the call site.
$invokeTestParams = @{
TestProjectPath = @($testProjectPaths)
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
$sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force
$sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force
if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) {
$sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force
}
if ($testResult.CoverageFiles) {
$sharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($testResult.CoverageFiles) -Force
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

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

View File

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

View File

@ -0,0 +1,118 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using an API key from an environment
variable (for example NPMJS_MAKS_IT). Uses a temporary .npmrc in the
workspace root for auth and supports --skip-duplicate semantics via npm.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$npmApiKeyEnvVar = $pluginSettings.npmApiKey
if ([string]::IsNullOrWhiteSpace($npmApiKeyEnvVar)) {
throw "NpmPublish plugin requires 'npmApiKey' in scriptSettings.json (environment variable name)."
}
$npmApiKey = [System.Environment]::GetEnvironmentVariable($npmApiKeyEnvVar)
if ([string]::IsNullOrWhiteSpace($npmApiKey)) {
throw "npm API key is not set. Set '$npmApiKeyEnvVar' and rerun."
}
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
'https://registry.npmjs.org'
}
else {
[string]$pluginSettings.registry
}
$access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
'public'
}
else {
[string]$pluginSettings.access
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$publishOrder = @([string]$pluginSettings.publishOrder)
}
}
if ($publishOrder.Count -eq 0) {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
$registryHost = ([uri]$registry).Host
$tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp"
$npmRcContent = @"
registry=$registry
//$registryHost/:_authToken=$npmApiKey
"@
Push-Location $workspaceRoot
try {
Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."
}
Write-Log -Level "OK" -Message " Published $packageName."
}
Write-Log -Level "OK" -Message " npm publish completed."
$shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
finally {
if (Test-Path $tempNpmRcPath -PathType Leaf) {
Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
}
Pop-Location
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

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

View File

@ -0,0 +1,178 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Coverage badge plugin for the test engine.
.DESCRIPTION
Reads line/branch/method coverage from shared engine context and writes SVG badges.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-BadgeColorInternal {
param(
[double]$percentage,
[psobject]$thresholds
)
if ($percentage -ge $thresholds.brightgreen) { return 'brightgreen' }
if ($percentage -ge $thresholds.green) { return 'green' }
if ($percentage -ge $thresholds.yellowgreen) { return 'yellowgreen' }
if ($percentage -ge $thresholds.yellow) { return 'yellow' }
if ($percentage -ge $thresholds.orange) { return 'orange' }
return 'red'
}
function New-BadgeSvgInternal {
param(
[string]$label,
[string]$value,
[string]$color
)
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
$totalWidth = $labelWidth + $valueWidth
$labelX = $labelWidth / 2
$valueX = $labelWidth + ($valueWidth / 2)
$colorMap = @{
brightgreen = '#4c1'
green = '#97ca00'
yellowgreen = '#a4a61d'
yellow = '#dfb317'
orange = '#fe7d37'
red = '#e05d44'
}
$hexColor = $colorMap[$color]
if (-not $hexColor) { $hexColor = '#9f9f9f' }
return @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
function Get-CoverageMetricsFromSharedContext {
param(
[Parameter(Mandatory = $true)]
$Shared
)
$line = $null
$branch = $null
$method = $null
if ($Shared.PSObject.Properties.Name -contains 'coverageLineRate') {
$line = [double]$Shared.coverageLineRate
}
if ($Shared.PSObject.Properties.Name -contains 'coverageBranchRate') {
$branch = [double]$Shared.coverageBranchRate
}
if ($Shared.PSObject.Properties.Name -contains 'coverageMethodRate') {
$method = [double]$Shared.coverageMethodRate
}
if ($null -eq $line -and $Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) {
$line = [double]$Shared.testResult.LineRate
$branch = [double]$Shared.testResult.BranchRate
$method = [double]$Shared.testResult.MethodRate
}
if ($null -eq $line) {
throw 'CoverageBadges requires coverage metrics on shared context. Run NpmJestTest or DotNetTest first.'
}
return @{
line = $line
branch = $branch
method = $method
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings
$badgesDir = $sharedSettings.badgesDir
if ($pluginSettings.badgesDir) {
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
$badgesDir = $badgesDirs[0]
}
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json."
}
if (-not (Test-Path $badgesDir)) {
New-Item -ItemType Directory -Path $badgesDir | Out-Null
}
$thresholds = $pluginSettings.colorThresholds
if ($null -eq $thresholds) {
$thresholds = [pscustomobject]@{
brightgreen = 80
green = 60
yellowgreen = 40
yellow = 20
orange = 10
red = 0
}
}
Write-Log -Level "STEP" -Message "Generating coverage badges..."
foreach ($badge in @($pluginSettings.badges)) {
$metricValue = $metrics[[string]$badge.metric]
if ($null -eq $metricValue) {
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'."
}
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds
$svg = New-BadgeSvgInternal -label $badge.label -value "$metricValue%" -color $color
$path = Join-Path $badgesDir $badge.name
$svg | Out-File -FilePath $path -Encoding utf8NoBOM
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
}
Write-Log -Level "OK" -Message "Badges generated in: $badgesDir"
Write-Log -Level "STEP" -Message "Commit the badges folder to update README."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -8,11 +8,14 @@
.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.
shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
(see ChangelogSupport.psm1).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
@ -43,7 +46,7 @@ function Get-GitHubRepositoryInternal {
return "$($matches['owner'])/$($matches['repo'])"
}
throw "Could not parse GitHub repo from source: $repoSource. Configure Plugins[].repository with 'owner/repo' or a GitHub URL."
throw "Could not parse GitHub repo from source: $repoSource. Configure plugins[].repository with 'owner/repo' or a GitHub URL."
}
function Get-ReleaseNotesInternal {
@ -61,11 +64,11 @@ function Get-ReleaseNotesInternal {
}
$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 = Get-LatestChangelogVersion -ReleaseNotesContent $releaseNotesContent
if ([string]::IsNullOrWhiteSpace($releaseNotesVersion)) {
throw "No version entry found in the configured release notes source. Expected Keep a Changelog header: ## [semver] - YYYY-MM-DD."
}
$releaseNotesVersion = $Matches[1]
if ($releaseNotesVersion -ne $Version) {
throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)."
}
@ -73,15 +76,14 @@ function Get-ReleaseNotesInternal {
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)
$section = Get-ChangelogReleaseNotesSection -ReleaseNotesContent $releaseNotesContent -Version $Version
if (-not $match.Success) {
throw "Release notes entry for version $Version not found."
if ([string]::IsNullOrWhiteSpace($section)) {
throw "Release notes entry for version $Version not found. Expected header: ## [$Version] - YYYY-MM-DD."
}
Write-Log -Level "OK" -Message " Release notes extracted."
return $match.Value.Trim()
return $section
}
function Invoke-Plugin {
@ -92,23 +94,24 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$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
$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."
throw "GitHub plugin requires 'githubToken' in scriptSettings.json."
}
$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
@ -117,24 +120,33 @@ function Invoke-Plugin {
}
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptsettings.json."
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)
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
elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$releaseAssetPaths = @($sharedSettings.packageFile.FullName)
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName
}
}
$requireReleaseAssets = $true
if ($null -ne $pluginSettings.requireReleaseAssets) {
$requireReleaseAssets = [bool]$pluginSettings.requireReleaseAssets
}
if ($releaseAssetPaths.Count -eq 0 -and $requireReleaseAssets) {
throw "GitHub release requires at least one prepared release asset (set requireReleaseAssets: false for notes-only npm releases)."
}
if ($releaseAssetPaths.Count -eq 0) {
throw "GitHub release requires at least one prepared release asset."
Write-Log -Level "INFO" -Message " Notes-only GitHub release (requireReleaseAssets: false)."
}
$repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository
@ -217,7 +229,7 @@ function Invoke-Plugin {
}
Write-Log -Level "OK" -Message " GitHub release created successfully."
$sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force
$sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
finally {
if ($null -ne $previousGhToken) {

View File

@ -0,0 +1,185 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Quality gate plugin (coverage threshold + optional .NET vulnerability scan).
.DESCRIPTION
Does not run tests or collect coverage. It reads whatever prior plugins left on the
shared engine context (same object passed to every plugin as .context).
Line coverage for threshold checks is resolved in order (first present wins):
- qualityLineCoverage (generic; any plugin may set this)
- coverageLineRate (conventional flat metric)
- testResult.LineRate (object from a test plugin; property name is conventional)
Configure coverageThreshold > 0 to require one of those inputs. With coverageThreshold 0
and scanVulnerabilities false, the plugin is a no-op.
When scanVulnerabilities is true, runs dotnet list package --vulnerable on projectFiles.
Use stageLabel "qualityGate" in scriptSettings.json; plugin: plugins/Platform/QualityGate.psm1 (`"name": "QualityGate"`).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function 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 Get-LineCoveragePercentFromSharedContext {
param(
[Parameter(Mandatory = $true)]
$Shared
)
foreach ($prop in @('qualityLineCoverage', 'coverageLineRate')) {
if ($Shared.PSObject.Properties.Name -contains $prop) {
$raw = $Shared.$prop
if ($null -eq $raw) { continue }
$asString = [string]$raw
if ([string]::IsNullOrWhiteSpace($asString)) { continue }
return [double]$asString
}
}
if ($Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) {
$tr = $Shared.testResult
if ($tr.PSObject.Properties.Name -contains 'LineRate') {
return [double]$tr.LineRate
}
}
return $null
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$coverageThresholdSetting = $pluginSettings.coverageThreshold
$failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities
$scanVulnerabilities = $true
if ($null -ne $pluginSettings.scanVulnerabilities) {
$scanVulnerabilities = [bool]$pluginSettings.scanVulnerabilities
}
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
}
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
$projectFiles = @($sharedSettings.projectFiles)
}
else {
$projectFiles = @()
}
$coverageThreshold = 0
if ($null -ne $coverageThresholdSetting) {
$coverageThreshold = [double]$coverageThresholdSetting
}
$needCoverageCheck = $coverageThreshold -gt 0
if (-not $needCoverageCheck -and -not $scanVulnerabilities) {
Write-Log -Level "INFO" -Message " Quality gate: no checks enabled (coverageThreshold 0, scanVulnerabilities false)."
return
}
$lineRate = $null
if ($needCoverageCheck) {
$lineRate = Get-LineCoveragePercentFromSharedContext -Shared $sharedSettings
if ($null -eq $lineRate) {
throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set one of: qualityLineCoverage, coverageLineRate, or testResult.LineRate (from an earlier plugin)."
}
Write-Log -Level "STEP" -Message "Checking line coverage threshold against shared context..."
if ($lineRate -lt $coverageThreshold) {
throw "Line coverage $lineRate% is below the configured threshold of $coverageThreshold%."
}
Write-Log -Level "OK" -Message " Coverage threshold met: $lineRate% >= $coverageThreshold%"
}
else {
Write-Log -Level "INFO" -Message " Coverage threshold check not required (coverageThreshold is 0)."
}
if (-not $scanVulnerabilities) {
Write-Log -Level "INFO" -Message " Vulnerability scan skipped (scanVulnerabilities is false)."
return
}
Assert-Command dotnet
$failOnVulnerabilities = $true
if ($null -ne $failOnVulnerabilitiesSetting) {
$failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting
}
if ($projectFiles.Count -eq 0) {
throw "QualityGate requires projectFiles when scanVulnerabilities is true."
}
$vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles
if ($vulnerabilities.Count -eq 0) {
Write-Log -Level "OK" -Message " No vulnerable packages detected."
return
}
foreach ($finding in $vulnerabilities) {
Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))"
$finding.Output -split "`r?`n" | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace($_)) {
Write-Log -Level "WARN" -Message " $_"
}
}
}
if ($failOnVulnerabilities) {
throw "Vulnerable packages were detected and failOnVulnerabilities is enabled."
}
Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,167 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish).
.DESCRIPTION
Place this plugin immediately before any publish plugins in scriptSettings.json. It sets
shared context skipPublishPlugins to false when all configured requirements pass, or true
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
tag version vs DotNetReleaseVersion, optional push tag to remote.
The engine preflight no longer reads git tags; this plugin sets context.tag from the
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-ExactTagOnHeadSilentlyInternal {
$raw = & git describe --tags --exact-match HEAD 2>&1
if ($LASTEXITCODE -ne 0) {
return $null
}
$s = ($raw | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($s)) {
return $null
}
return $s
}
function Invoke-NotMetInternal {
param(
[Parameter(Mandatory = $true)]
$Shared,
[Parameter(Mandatory = $true)]
[string]$When,
[Parameter(Mandatory = $true)]
[string]$Reason
)
$Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force
if ($When -eq 'fail') {
Write-Log -Level "ERROR" -Message "ReleasePublishGuard: $Reason"
exit 1
}
Write-Log -Level "WARN" -Message " Publish suppressed: $Reason"
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "PluginSupport" -RequiredCommand "Get-PluginBranches"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
$pluginSettings = $Settings
$shared = $Settings.context
$when = 'skip'
if ($null -ne $pluginSettings.whenRequirementsNotMet) {
$when = [string]$pluginSettings.whenRequirementsNotMet
}
if ($when -notin @('skip', 'fail')) {
throw "ReleasePublishGuard: whenRequirementsNotMet must be 'skip' or 'fail'."
}
$shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $false -Force
Write-Log -Level "STEP" -Message "Release publish guard..."
$allowed = @(Get-PluginBranches -Plugin $pluginSettings)
if ($allowed.Count -gt 0 -and $allowed -notcontains '*' -and $allowed -notcontains $shared.currentBranch) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "branch '$($shared.currentBranch)' is not in the guard branches list."
return
}
$requireClean = $false
if ($null -ne $pluginSettings.requireCleanWorkingTree) {
$requireClean = [bool]$pluginSettings.requireCleanWorkingTree
}
if ($requireClean) {
$dirtyRaw = Get-GitStatusShort
if (-not [string]::IsNullOrWhiteSpace([string]$dirtyRaw)) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "working tree is not clean (requireCleanWorkingTree is true)."
return
}
}
$requireTag = $true
if ($null -ne $pluginSettings.requireExactTagOnHead) {
$requireTag = [bool]$pluginSettings.requireExactTagOnHead
}
$tag = $null
if ($requireTag) {
$tag = Get-ExactTagOnHeadSilentlyInternal
if ([string]::IsNullOrWhiteSpace($tag)) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "no exact semver tag on HEAD (git describe --tags --exact-match)."
return
}
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
return
}
$tagVersion = $Matches[1]
$mustMatch = $true
if ($null -ne $pluginSettings.tagVersionMustMatchReleaseVersion) {
$mustMatch = [bool]$pluginSettings.tagVersionMustMatchReleaseVersion
}
elseif ($null -ne $pluginSettings.tagVersionMustMatchNpmRelease) {
$mustMatch = [bool]$pluginSettings.tagVersionMustMatchNpmRelease
}
elseif ($null -ne $pluginSettings.tagVersionMustMatchDotNetRelease) {
$mustMatch = [bool]$pluginSettings.tagVersionMustMatchDotNetRelease
}
if ($mustMatch -and $tagVersion -ne [string]$shared.version) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag version $tagVersion does not match release version $($shared.version)."
return
}
$shared | Add-Member -NotePropertyName tag -NotePropertyValue $tag -Force
}
$ensureRemote = $true
if ($null -ne $pluginSettings.ensureTagOnRemote) {
$ensureRemote = [bool]$pluginSettings.ensureTagOnRemote
}
if ($ensureRemote -and $requireTag -and -not [string]::IsNullOrWhiteSpace($tag)) {
$remote = 'origin'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.remoteName)) {
$remote = [string]$pluginSettings.remoteName
}
Write-Log -Level "STEP" -Message "Verifying tag on remote '$remote'..."
if (-not (Test-RemoteTagExists -Tag $tag -Remote $remote)) {
Write-Log -Level "WARN" -Message " Tag $tag not on remote. Pushing..."
Push-TagToRemote -Tag $tag -Remote $remote
}
else {
Write-Log -Level "OK" -Message " Tag exists on remote."
}
}
Write-Log -Level "OK" -Message " Publish guard passed; publish plugins will run."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -13,7 +13,7 @@
4. Deletes and recreates the tag on the amended commit
5. Force pushes the branch and tag to remote
All configuration is in scriptsettings.json.
All configuration is in scriptSettings.json.
.PARAMETER DryRun
If specified, shows what would be done without making changes.
@ -25,7 +25,7 @@
pwsh -File .\Force-AmendTaggedCommit.ps1 -DryRun
.NOTES
CONFIGURATION (scriptsettings.json):
CONFIGURATION (scriptSettings.json):
- git.remote: Remote name to push to (default: "origin")
- git.confirmBeforeAmend: Prompt before amending (default: true)
- git.confirmWhenNoChanges: Prompt if no pending changes (default: true)
@ -37,27 +37,26 @@ param(
[switch]$DryRun
)
# 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
$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent
$modulesDir = Join-Path $srcDir 'modules'
#region Import Modules
# Import shared ScriptConfig module (settings loading + dependency checks)
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
# Import shared GitTools module (git operations used by this script)
$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
$gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1"
if (-not (Test-Path $gitToolsModulePath)) {
Write-Error "GitTools module not found at: $gitToolsModulePath"
exit 1
}
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1

View File

@ -8,16 +8,16 @@
.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
scriptSettings.json files in subfolders, and copies the cloned source
contents into that parent directory.
All configuration is stored in scriptsettings.json.
All configuration is stored in scriptSettings.json.
.EXAMPLE
pwsh -File .\Update-RepoUtils.ps1
.NOTES
CONFIGURATION (scriptsettings.json):
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
@ -37,19 +37,19 @@ param(
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
$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent
$modulesDir = Join-Path $srcDir 'modules'
# Refresh the parent directory that contains the shared modules and sibling tools.
# Refresh the src directory that contains modules, engines, plugins, and tools.
$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) {
Split-Path $scriptDir -Parent
$srcDir
}
else {
[System.IO.Path]::GetFullPath($TargetDirectoryOverride)
}
$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path)
$selfUpdateDirectory = 'Update-RepoUtils'
$selfUpdateDirectory = [System.IO.Path]::Combine('tools', 'Update-RepoUtils')
function ConvertTo-NormalizedRelativePath {
param(
@ -90,13 +90,13 @@ function Test-IsInRelativeDirectory {
#region Import Modules
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
@ -118,7 +118,7 @@ $settings = Get-ScriptSettings -ScriptDir $scriptDir
$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' }
$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptSettings.json' }
$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 }
[string[]]$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) {
@(
@ -129,7 +129,10 @@ $cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.c
)
}
else {
@([System.IO.Path]::Combine('Release-Package', 'CustomPlugins'))
@(
[System.IO.Path]::Combine('engines', 'release', 'custom'),
[System.IO.Path]::Combine('engines', 'test', 'custom')
)
}
#endregion
@ -140,7 +143,7 @@ Assert-Command git
Assert-Command pwsh
if ([string]::IsNullOrWhiteSpace($repositoryUrl)) {
Write-Error "repository.url is required in scriptsettings.json."
Write-Error "repository.url is required in scriptSettings.json."
exit 1
}

View File

@ -2,14 +2,15 @@
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Update RepoUtils Script Settings",
"description": "Configuration for the Update-RepoUtils utility.",
"dryRun": true,
"dryRun": false,
"repository": {
"url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git",
"sourceSubdirectory": "src",
"preserveFileName": "scriptsettings.json",
"preserveFileName": "scriptSettings.json",
"cloneDepth": 1,
"skippedRelativeDirectories": [
"Release-Package/CustomPlugins"
"engines/release/custom",
"engines/test/custom"
]
}
}