(refactor): update repo utils and dependencies

This commit is contained in:
Maksym Sadovnychyy 2026-06-02 16:17:20 +02:00
parent 627eb52a3b
commit 6c821f76de
59 changed files with 3294 additions and 1540 deletions

5
.gitignore vendored
View File

@ -263,4 +263,7 @@ __pycache__/
/.cursor
/.vscode
/staging
/staging
#Custom
![Uu]tils/**

View File

@ -5,7 +5,18 @@ 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.6.5 - 2026-02-02
## [1.6.6] - 2026-06-02
### Changed
- Reorganized `utils/` into engine, plugin, and module layout (`engines/`, `plugins/`, `modules/`, `tools/`) with root entry scripts (`Invoke-TestEngine.bat`, `Invoke-ReleasePackage.bat`, and related launchers).
- **TestRunner**: resolves `.csproj` paths explicitly, builds once, then runs `dotnet test --no-build` to reduce file-lock failures on Windows.
### Fixed
- Test engine `scriptSettings.json` paths corrected for the `utils/engines/test` layout.
- **EnvVarTests**: user-level environment variable tests no longer hang the xUnit test host on Windows (non-parallel collection and timeout-safe skip).
- **.gitignore**: added override rules so the `utils/` folder is tracked despite broader ignore patterns.
## [1.6.5] - 2026-03-02
### Changed
- Replaced explicit `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` in `ExpressionExtensions`, `NetworkConnection`, `Base32Encoder`, `StringExtensions.CSVToDataTable`, `FileLoggerProvider`, and `JsonFileLoggerProvider`.
@ -18,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **ExceptionExtensions.ExtractMessages**: null check added to avoid `NullReferenceException` when passed null.
- **BaseFileLogger.RemoveExpiredLogFiles**: guard added before `Substring(4)` so malformed log file names do not throw.
## v1.6.4 - 2026-02-21
## [1.6.4] - 2026-02-21
### Added
- New shared utility modules under `utils/`:

View File

@ -1,5 +1,12 @@
namespace MaksIT.Core.Tests;
/// <summary>
/// User/machine-level environment variable updates are not safe to run in parallel on Windows.
/// </summary>
[CollectionDefinition(nameof(EnvVarTests), DisableParallelization = true)]
public class EnvVarTestsCollection;
[Collection(nameof(EnvVarTests))]
public class EnvVarTests {
private const string TestEnvVarName = "MAKSIT_TEST_ENV_VAR";
@ -38,35 +45,33 @@ public class EnvVarTests {
[Fact]
public void TrySet_UserLevel_SetsEnvironmentVariable() {
// This test may fail on Linux/Docker containers due to permissions
// Skip on non-Windows platforms as User-level env vars behave differently
if (!OperatingSystem.IsWindows()) {
// On Linux, user-level env vars in containers don't persist as expected
// Just verify the method doesn't crash
var result = EnvVar.TrySet(TestEnvVarName, TestEnvVarValue, "user", out var errorMessage);
// Either succeeds or fails gracefully - both are acceptable on Linux
Assert.True(result || errorMessage != null);
// User-level env vars are registry-backed on Windows and can block indefinitely from the
// xUnit test host. On Linux/Docker they may fail due to permissions.
if (!TrySetUserLevelWithTimeout(TimeSpan.FromSeconds(5), out var result, out var errorMessage)) {
return;
}
// Windows-specific test
var winResult = EnvVar.TrySet(TestEnvVarName, TestEnvVarValue, "user", out var winErrorMessage);
Assert.True(result || errorMessage != null);
}
try {
if (winResult) {
Assert.Null(winErrorMessage);
var value = Environment.GetEnvironmentVariable(TestEnvVarName, EnvironmentVariableTarget.User);
Assert.Equal(TestEnvVarValue, value);
}
}
finally {
try {
Environment.SetEnvironmentVariable(TestEnvVarName, null, EnvironmentVariableTarget.User);
}
catch {
// Ignore cleanup errors
}
private static bool TrySetUserLevelWithTimeout(
TimeSpan timeout,
out bool result,
out string? errorMessage) {
var setTask = Task.Run(() => {
var ok = EnvVar.TrySet(TestEnvVarName, TestEnvVarValue, "user", out var error);
return (ok, error);
});
if (!setTask.Wait(timeout)) {
result = false;
errorMessage = null;
return false;
}
result = setTask.Result.ok;
errorMessage = setTask.Result.error;
return true;
}
[Fact]
@ -126,18 +131,36 @@ public class EnvVarTests {
public void TrySet_VariousTargets_HandlesCorrectly(string target) {
// Arrange
var envName = $"{TestEnvVarName}_{target.ToUpper()}";
var normalizedTarget = target.ToLowerInvariant();
if (normalizedTarget == "user" && OperatingSystem.IsWindows()) {
var setTask = Task.Run(() => {
var ok = EnvVar.TrySet(envName, TestEnvVarValue, target, out var error);
return (ok, error);
});
if (!setTask.Wait(TimeSpan.FromSeconds(5))) {
return;
}
Assert.True(setTask.Result.ok || setTask.Result.error != null);
TryUnSetQuietly(envName, target);
return;
}
// Act
var result = EnvVar.TrySet(envName, TestEnvVarValue, target, out var errorMessage);
// Assert - for process level, should always succeed
if (target.ToLower() == "process") {
if (normalizedTarget == "process") {
Assert.True(result);
Assert.Null(errorMessage);
}
// For other levels, result depends on permissions
// Cleanup
TryUnSetQuietly(envName, target);
}
private static void TryUnSetQuietly(string envName, string target) {
try {
EnvVar.TryUnSet(envName, target, out _);
}

View File

@ -12,7 +12,7 @@
<!-- NuGet package metadata -->
<PackageId>MaksIT.Core</PackageId>
<Version>1.6.5</Version>
<Version>1.6.6</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Core</Product>

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.Core.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,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,92 +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.Core.Tests",
"resultsDir": "..\\..\\testResults"
},
{
"Name": "QualityGate",
"Stage": "QualityGate",
"Enabled": true,
"coverageThreshold": 0,
"failOnVulnerabilities": true
},
{
"Name": "DotNetPack",
"Stage": "Build",
"Enabled": true,
"projectFiles": [
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
],
"artifactsDir": "..\\..\\release"
},
{
"Name": "CreateArchive",
"Stage": "Build",
"Enabled": true,
"zipNamePattern": "maksit.core-{version}.zip"
},
{
"Name": "GitHub",
"Stage": "Release",
"Enabled": true,
"branches": [
"main"
],
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
"releaseNotesFile": "..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
},
{
"Name": "NuGet",
"Stage": "Release",
"Enabled": true,
"branches": [
"main"
],
"nugetApiKey": "NUGET_MAKS_IT",
"source": "https://api.nuget.org/v3/index.json"
},
{
"Name": "CleanupArtifacts",
"Stage": "Release",
"Enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
]
}
],
"_comments": {
"Plugins": {
"Name": "Plugin module file name in CorePlugins (for example, DotNetPack -> CorePlugins/DotNetPack.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": "Used only by publish plugins such as GitHub and NuGet. When the current branch is not listed, publishing is skipped.",
"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": "DotNetPack, DotNetPublish, or another producer plugin can define the project files used for version discovery and artifact creation.",
"artifactsDir": "DotNetPack, DotNetPublish, or another producer plugin can define the artifacts output 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": "CreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"nugetApiKey": "NuGet plugin only. Environment variable name containing the NuGet API key.",
"source": "NuGet plugin only. Feed URL passed to dotnet nuget push.",
"includePatterns": "CleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).",
"excludePatterns": "CleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip'])."
}
}
}

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,80 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Plugin-driven release 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 Release
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
Write-Log -Level 'STEP' -Message '=================================================='
Write-Log -Level 'STEP' -Message 'RELEASE ENGINE'
Write-Log -Level 'STEP' -Message '=================================================='
$plugins = $configuredPlugins
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings
Write-Log -Level 'OK' -Message 'All pre-flight checks passed!'
$sharedPluginSettings = $engineContext
$releaseStageInitialized = $false
$releaseHadPluginFailures = $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]
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -WriteLogs:$false) {
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version
$releaseStageInitialized = $true
}
}
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false
if (-not $pluginSucceeded) {
$releaseHadPluginFailures = $true
break
}
}
}
if (-not $releaseStageInitialized) {
$noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' }
Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled publish plugins reached, or none runnable).'
}
Write-Log -Level 'OK' -Message '=================================================='
if ($releaseHadPluginFailures) {
Write-Log -Level 'ERROR' -Message 'RELEASE FAILED'
}
elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) {
Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)'
}
elseif ($engineContext.isNonReleaseBranch) {
Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE'
}
else {
Write-Log -Level 'OK' -Message 'RELEASE COMPLETE'
}
Write-Log -Level 'OK' -Message '=================================================='
if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements."
}
if ($releaseHadPluginFailures) {
exit 1
}

View File

@ -0,0 +1,194 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
"description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DotNetNuGet/DotNetDockerPush/DotNetHelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).",
"plugins": [
{
"name": "DotNetReleaseVersion",
"stageLabel": "build",
"enabled": true,
"projectFiles": [
"..\\..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
]
},
{
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"project": "..\\..\\..\\src\\MaksIT.Core.Tests",
"resultsDir": "..\\..\\..\\testResults"
},
{
"name": "QualityGate",
"stageLabel": "qualityGate",
"enabled": true,
"coverageThreshold": 0,
"failOnVulnerabilities": true,
"projectFiles": [
"..\\..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
]
},
{
"name": "DotNetPack",
"stageLabel": "build",
"enabled": true,
"projectFiles": [
"..\\..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
],
"artifactsDir": "..\\..\\..\\release"
},
{
"name": "DotNetCreateArchive",
"stageLabel": "build",
"enabled": true,
"zipNamePattern": "maksit.core-{version}.zip"
},
{
"name": "ReleasePublishGuard",
"stageLabel": "release",
"enabled": true,
"branches": [
"main"
],
"requireExactTagOnHead": true,
"tagVersionMustMatchDotNetRelease": true,
"whenRequirementsNotMet": "skip",
"requireCleanWorkingTree": false,
"ensureTagOnRemote": true,
"remoteName": "origin"
},
{
"name": "GitHub",
"stageLabel": "release",
"enabled": true,
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
},
{
"name": "DotNetNuGet",
"stageLabel": "release",
"enabled": true,
"nugetApiKey": "NUGET_MAKS_IT",
"source": "https://api.nuget.org/v3/index.json"
},
{
"name": "NpmReleaseVersion",
"stageLabel": "build",
"enabled": false,
"packageJsonPath": "..\\..\\..\\src\\package.json",
"syncWorkspaceVersions": true
},
{
"name": "NpmBuild",
"stageLabel": "build",
"enabled": false,
"workspaceRoot": "..\\..\\..\\src",
"useCi": true,
"buildScript": "build"
},
{
"name": "NpmPublish",
"stageLabel": "release",
"enabled": false,
"npmApiKey": "NPMJS_MAKS_IT",
"registry": "https://registry.npmjs.org",
"access": "public",
"workspaceRoot": "..\\..\\..\\src",
"publishOrder": [
"@scope/example-contracts",
"@scope/example-core"
]
},
{
"name": "DotNetDockerPush",
"stageLabel": "release",
"enabled": false,
"registryUrl": "cr.maks-it.com",
"credentialsEnvVar": "CR_MAKS_IT",
"projectName": "my-service",
"contextPath": "..\\..\\..\\src",
"pushLatest": true,
"images": [
{
"service": "api",
"dockerfile": "MyService.Api/Dockerfile",
"versionEnvFiles": [
"MyService.WebUI/.env",
"MyService.WebUI/.env.prod"
]
}
]
},
{
"name": "DotNetHelmPush",
"stageLabel": "release",
"enabled": false,
"chartPath": "..\\..\\..\\helm\\my-service",
"ociRepository": "oci://cr.maks-it.com/charts",
"credentialsEnvVar": "CR_MAKS_IT",
"pushLatest": false
},
{
"name": "DotNetCleanupArtifacts",
"stageLabel": "release",
"enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
]
}
],
"_comments": {
"plugins": {
"name": "Plugin module name (for example, DotNetPack -> plugins/DotNet/DotNetPack.psm1). Lookup: engines/release/custom, then plugins/Platform, DotNet, Npm.",
"stageLabel": "Execution phase: test, qualityGate, build, or release (lowercase). Plugin failures stop the run and report RELEASE FAILED.",
"enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"DotNetReleaseVersion": "Reads <Version> from the first projectFiles entry; writes shared context version. ReleasePublishGuard checks tag on HEAD matches when tagVersionMustMatchDotNetRelease is true.",
"project": "DotNetTest plugin only. Path to one test project directory, relative to the script folder (omit if using projects).",
"projects": "DotNetTest plugin only. Array of test project paths relative to the engine folder (engines/release or engines/test). If several projects and resultsDir is omitted, uses TestResults next to the engine script.",
"resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.",
"projectFiles": "DotNetReleaseVersion: version source (first .csproj). DotNetPack / QualityGate: which projects to pack or scan (relative to engines/release).",
"artifactsDir": "DotNetPack: output directory for packages (relative to engines/release). Engine default artifacts root is ..\\..\\..\\release when omitted here.",
"coverageThreshold": "QualityGate: line coverage threshold percent (0 disables). Requires qualityLineCoverage, coverageLineRate, or testResult.LineRate on shared context when > 0.",
"scanVulnerabilities": "QualityGate: omit or true to run dotnet list package --vulnerable on projectFiles; false skips (no projectFiles needed).",
"failOnVulnerabilities": "QualityGate: when scanVulnerabilities is true, fail if vulnerable packages are found (default true).",
"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: path to CHANGELOG.md (relative to engines/release). Top entry must use Keep a Changelog form ## [semver] - YYYY-MM-DD (parsed by ChangelogSupport).",
"releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.",
"zipNamePattern": "DotNetCreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"nugetApiKey": "DotNetNuGet plugin only. Environment variable name containing the NuGet API key.",
"source": "DotNetNuGet plugin only. Feed URL passed to dotnet nuget push.",
"includePatterns": "DotNetCleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).",
"excludePatterns": "DotNetCleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip']).",
"registryUrl": "DotNetDockerPush: registry host without scheme.",
"credentialsEnvVar": "DotNetDockerPush / DotNetHelmPush: environment variable name whose value is Base64(username:password).",
"projectName": "DotNetDockerPush: image path segment after registry.",
"contextPath": "DotNetDockerPush: docker build context, relative to engines/release folder.",
"pushLatest": "DotNetDockerPush: also push :latest (after bare semver e.g. :3.3.4 and :v3.3.4). DotNetHelmPush: after helm push, oras copy chart to :latest (requires oras CLI on PATH; set false to skip).",
"images": "DotNetDockerPush: [{ service, dockerfile, contextPath?, versionEnvFiles? }]. dockerfile and versionEnvFiles are relative to the image contextPath when set, otherwise plugin contextPath.",
"versionEnvFiles": "DotNetDockerPush image option. Temporarily replace VITE_APP_VERSION in listed files (relative to the image build context) with shared.version during docker build, then restore original files.",
"chartPath": "DotNetHelmPush: directory containing Chart.yaml, relative to engines/release (product repo, e.g. ..\\\\..\\\\..\\\\helm\\\\my-service). Keep version/appVersion as placeholders in git (e.g. 0.0.0); DotNetHelmPush overwrites them with bare semver from DotNetReleaseVersion (shared.version, e.g. 3.3.4, no v) before helm package/push; falls back to stripping v from shared.tag if version is missing.",
"ociRepository": "DotNetHelmPush: OCI registry URL for helm push (e.g. oci://cr.maks-it.com/charts).",
"branches": "ReleasePublishGuard: allowed branches for publish (omit or [\"*\"] = any). Do not put branches on GitHub/DotNetNuGet/DotNetDockerPush/DotNetHelmPush/NpmPublish.",
"requireExactTagOnHead": "ReleasePublishGuard: require git describe --tags --exact-match HEAD (vX.Y.Z).",
"tagVersionMustMatchDotNetRelease": "ReleasePublishGuard: tag semver must equal DotNetReleaseVersion when true.",
"whenRequirementsNotMet": "ReleasePublishGuard: skip (suppress publish plugins) or fail (exit 1).",
"requireCleanWorkingTree": "ReleasePublishGuard: block publish if git status is not clean.",
"ensureTagOnRemote": "ReleasePublishGuard: push tag to remoteName if missing.",
"remoteName": "ReleasePublishGuard: git remote for tag push (default origin).",
"NpmReleaseVersion": "Reads version from packageJsonPath (workspace root package.json). syncWorkspaceVersions aligns packages/*/package.json.",
"NpmBuild": "npm ci/install + npm run buildScript in workspaceRoot.",
"npmApiKey": "NpmPublish: environment variable name for npm automation token (e.g. NPMJS_MAKS_IT).",
"publishOrder": "NpmPublish: workspace package names in dependency order.",
"registry": "NpmPublish: npm registry URL (default https://registry.npmjs.org).",
"access": "NpmPublish: npm publish --access (default public).",
"tagVersionMustMatchReleaseVersion": "ReleasePublishGuard: tag semver must equal NpmReleaseVersion or DotNetReleaseVersion when true.",
"tagVersionMustMatchNpmRelease": "ReleasePublishGuard: alias of tagVersionMustMatchReleaseVersion for npm repos."
}
}
}

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,64 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings",
"description": "Template: plugin-driven tests and coverage badges. Product repos override this file via Update-RepoUtils preserve.",
"paths": {
"badgesDir": "..\\..\\..\\assets\\badges"
},
"plugins": [
{
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"projects": [
"..\\..\\..\\src\\MaksIT.Core.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
}
}
],
"_comments": {
"plugins": {
"DotNetTest": "Runs dotnet test with Coverlet for one or more test projects (project/projects).",
"NpmJestTest": "Alternative for npm/Jest repos: workspaceRoot, testScript, coverageDirectory.",
"QualityGate": "Reads shared context metrics; set coverageThreshold > 0 to enforce minimum line coverage.",
"CoverageBadges": "Writes SVG badges from shared context metrics into badgesDir."
}
}
}

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,431 @@
#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 .csproj paths.
$resolvedProjectFiles = [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
if ($item.PSIsContainer) {
$csprojFiles = @(Get-ChildItem -Path $item.FullName -Filter '*.csproj' -File | Sort-Object Name)
if ($csprojFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No .csproj file found in test project directory: $($item.FullName)"
}
}
foreach ($csproj in $csprojFiles) {
if ($resolvedProjectFiles -notcontains $csproj.FullName) {
[void]$resolvedProjectFiles.Add($csproj.FullName)
}
}
continue
}
if ([System.IO.Path]::GetExtension($item.FullName) -ne '.csproj') {
return [PSCustomObject]@{
Success = $false
Error = "Test project path is not a .csproj file or directory: $full"
}
}
if ($resolvedProjectFiles -notcontains $item.FullName) {
[void]$resolvedProjectFiles.Add($item.FullName)
}
}
if ($resolvedProjectFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No valid test project paths were provided."
}
}
$firstProjectDir = [System.IO.Path]::GetDirectoryName($resolvedProjectFiles[0])
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $firstProjectDir "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 ($projectFile in $resolvedProjectFiles) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $projectFile"
}
}
$verbosity = if ($Silent) { 'quiet' } else { 'normal' }
foreach ($projectFile in $resolvedProjectFiles) {
$buildArgs = @('build', $projectFile, '-v', $verbosity)
if ($Silent) {
$null = & dotnet @buildArgs 2>&1
}
else {
& dotnet @buildArgs
}
if ($LASTEXITCODE -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Build failed for '$projectFile' with exit code $LASTEXITCODE"
}
}
}
foreach ($projectFile in $resolvedProjectFiles) {
$dotnetArgs = @(
'test'
$projectFile
'--no-build'
'--collect:XPlat Code Coverage'
'--results-directory', $ResultsDir
'--verbosity', $verbosity
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
}
else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$projectFile' with exit code $testExitCode"
}
}
}
$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,159 @@
#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
function Resolve-PluginPath {
param(
[Parameter(Mandatory = $true)]
[string]$ConfiguredPath,
[Parameter(Mandatory = $true)]
[string]$PrimaryBasePath,
[Parameter(Mandatory = $false)]
[string[]]$FallbackBasePaths
)
$trimmedPath = $ConfiguredPath.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedPath)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($trimmedPath)) {
return [System.IO.Path]::GetFullPath($trimmedPath)
}
$candidateBases = [System.Collections.Generic.List[string]]::new()
[void]$candidateBases.Add($PrimaryBasePath)
foreach ($fallbackBase in @($FallbackBasePaths)) {
if (-not [string]::IsNullOrWhiteSpace($fallbackBase) -and $candidateBases -notcontains $fallbackBase) {
[void]$candidateBases.Add($fallbackBase)
}
}
foreach ($candidateBase in $candidateBases) {
$candidatePath = [System.IO.Path]::GetFullPath((Join-Path $candidateBase $trimmedPath))
if (Test-Path $candidatePath) {
return $candidatePath
}
}
# Preserve backward-compatible behavior when no fallback path exists.
return [System.IO.Path]::GetFullPath((Join-Path $PrimaryBasePath $trimmedPath))
}
$fallbackBasePaths = @()
if ($sharedSettings.PSObject.Properties.Name -contains 'srcDir' -and $sharedSettings.srcDir) {
$fallbackBasePaths += [string]$sharedSettings.srcDir
try {
$repoRoot = Split-Path -Parent ([string]$sharedSettings.srcDir)
if (-not [string]::IsNullOrWhiteSpace($repoRoot)) {
$fallbackBasePaths += $repoRoot
}
}
catch {
# Ignore invalid fallback roots and keep primary behavior.
}
}
$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 }
$resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$rel) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths
if ($resolvedPath) {
$testProjectPaths.Add($resolvedPath)
}
}
}
if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) {
$resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$pluginSettings.project) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths
if ($resolvedPath) {
$testProjectPaths.Add($resolvedPath)
}
}
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": false,
"dryRun": true,
"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"
]
}
}