(refactor): update repo utils and dependencies
This commit is contained in:
parent
627eb52a3b
commit
6c821f76de
5
.gitignore
vendored
5
.gitignore
vendored
@ -263,4 +263,7 @@ __pycache__/
|
||||
|
||||
/.cursor
|
||||
/.vscode
|
||||
/staging
|
||||
/staging
|
||||
|
||||
#Custom
|
||||
![Uu]tils/**
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@ -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/`:
|
||||
|
||||
@ -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 _);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
3
utils/Force-AmendTaggedCommit.bat
Normal file
3
utils/Force-AmendTaggedCommit.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1" %*
|
||||
pause
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
pause
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||
pause
|
||||
@ -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
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
3
utils/Invoke-ReleasePackage.bat
Normal file
3
utils/Invoke-ReleasePackage.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
|
||||
pause
|
||||
3
utils/Invoke-TestEngine.bat
Normal file
3
utils/Invoke-TestEngine.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
|
||||
pause
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
|
||||
pause
|
||||
@ -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
|
||||
@ -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'])."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
3
utils/Update-RepoUtils.bat
Normal file
3
utils/Update-RepoUtils.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %*
|
||||
pause
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
|
||||
pause
|
||||
80
utils/engines/release/Invoke-ReleasePackage.ps1
Normal file
80
utils/engines/release/Invoke-ReleasePackage.ps1
Normal 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
|
||||
}
|
||||
194
utils/engines/release/scriptSettings.json
Normal file
194
utils/engines/release/scriptSettings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
50
utils/engines/test/Invoke-TestEngine.ps1
Normal file
50
utils/engines/test/Invoke-TestEngine.ps1
Normal 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
|
||||
}
|
||||
0
utils/engines/test/custom/.gitkeep
Normal file
0
utils/engines/test/custom/.gitkeep
Normal file
64
utils/engines/test/scriptSettings.json
Normal file
64
utils/engines/test/scriptSettings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
56
utils/modules/ChangelogSupport.psm1
Normal file
56
utils/modules/ChangelogSupport.psm1
Normal 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
|
||||
225
utils/modules/Engine/EngineContext.psm1
Normal file
225
utils/modules/Engine/EngineContext.psm1
Normal 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
|
||||
|
||||
|
||||
|
||||
35
utils/modules/Engine/Import-EngineModules.ps1
Normal file
35
utils/modules/Engine/Import-EngineModules.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
151
utils/modules/Engine/ReleaseSupport.psm1
Normal file
151
utils/modules/Engine/ReleaseSupport.psm1
Normal 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
|
||||
38
utils/modules/Engine/TestSupport.psm1
Normal file
38
utils/modules/Engine/TestSupport.psm1
Normal 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
|
||||
@ -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 {
|
||||
@ -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
|
||||
431
utils/modules/TestRunner.psm1
Normal file
431
utils/modules/TestRunner.psm1
Normal 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
|
||||
@ -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)) {
|
||||
@ -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
|
||||
245
utils/plugins/DotNet/DotNetDockerPush.psm1
Normal file
245
utils/plugins/DotNet/DotNetDockerPush.psm1
Normal 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
|
||||
181
utils/plugins/DotNet/DotNetHelmPush.psm1
Normal file
181
utils/plugins/DotNet/DotNetHelmPush.psm1
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
128
utils/plugins/DotNet/DotNetPack.psm1
Normal file
128
utils/plugins/DotNet/DotNetPack.psm1
Normal 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
|
||||
@ -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
|
||||
41
utils/plugins/DotNet/DotNetReleaseVersion.psm1
Normal file
41
utils/plugins/DotNet/DotNetReleaseVersion.psm1
Normal 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
|
||||
|
||||
|
||||
159
utils/plugins/DotNet/DotNetTest.psm1
Normal file
159
utils/plugins/DotNet/DotNetTest.psm1
Normal 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
|
||||
89
utils/plugins/Npm/NpmBuild.psm1
Normal file
89
utils/plugins/Npm/NpmBuild.psm1
Normal 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
|
||||
83
utils/plugins/Npm/NpmJestTest.psm1
Normal file
83
utils/plugins/Npm/NpmJestTest.psm1
Normal 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
|
||||
118
utils/plugins/Npm/NpmPublish.psm1
Normal file
118
utils/plugins/Npm/NpmPublish.psm1
Normal 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
|
||||
99
utils/plugins/Npm/NpmReleaseVersion.psm1
Normal file
99
utils/plugins/Npm/NpmReleaseVersion.psm1
Normal 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
|
||||
178
utils/plugins/Platform/CoverageBadges.psm1
Normal file
178
utils/plugins/Platform/CoverageBadges.psm1
Normal 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
|
||||
@ -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) {
|
||||
185
utils/plugins/Platform/QualityGate.psm1
Normal file
185
utils/plugins/Platform/QualityGate.psm1
Normal 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
|
||||
167
utils/plugins/Platform/ReleasePublishGuard.psm1
Normal file
167
utils/plugins/Platform/ReleasePublishGuard.psm1
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user