diff --git a/.gitignore b/.gitignore index 85ed164..72fc256 100644 --- a/.gitignore +++ b/.gitignore @@ -263,4 +263,7 @@ __pycache__/ /.cursor /.vscode -/staging \ No newline at end of file +/staging + +#Custom +![Uu]tils/** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 35da191..7fe4664 100644 --- a/CHANGELOG.md +++ b/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/`: diff --git a/src/MaksIT.Core.Tests/EnvVarTests.cs b/src/MaksIT.Core.Tests/EnvVarTests.cs index de1b0e8..99ffe98 100644 --- a/src/MaksIT.Core.Tests/EnvVarTests.cs +++ b/src/MaksIT.Core.Tests/EnvVarTests.cs @@ -1,5 +1,12 @@ namespace MaksIT.Core.Tests; +/// +/// User/machine-level environment variable updates are not safe to run in parallel on Windows. +/// +[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 _); } diff --git a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj index 740c0a3..fb016b9 100644 --- a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj +++ b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj @@ -10,12 +10,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index 7d1bedb..9efac36 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -12,7 +12,7 @@ MaksIT.Core - 1.6.5 + 1.6.6 Maksym Sadovnychyy MAKS-IT MaksIT.Core @@ -42,21 +42,21 @@ - - - - - - - - - - + + + + + + + + + + - + diff --git a/utils/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit.bat new file mode 100644 index 0000000..67b7d76 --- /dev/null +++ b/utils/Force-AmendTaggedCommit.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1" %* +pause diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat deleted file mode 100644 index 20029f8..0000000 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" -pause diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat deleted file mode 100644 index 2790074..0000000 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" -pause diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 deleted file mode 100644 index 24f7d09..0000000 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 +++ /dev/null @@ -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 @" - - $label`: $value - - - - - - - - - - - - - - - $label - - $value - - -"@ -} - -#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 diff --git a/utils/Generate-CoverageBadges/scriptsettings.json b/utils/Generate-CoverageBadges/scriptsettings.json deleted file mode 100644 index 23a025c..0000000 --- a/utils/Generate-CoverageBadges/scriptsettings.json +++ /dev/null @@ -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." - } -} diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat new file mode 100644 index 0000000..85d776a --- /dev/null +++ b/utils/Invoke-ReleasePackage.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* +pause diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat new file mode 100644 index 0000000..0cfd13f --- /dev/null +++ b/utils/Invoke-TestEngine.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +pause diff --git a/utils/Release-Package/CorePlugins/DotNetPack.psm1 b/utils/Release-Package/CorePlugins/DotNetPack.psm1 deleted file mode 100644 index 8353217..0000000 --- a/utils/Release-Package/CorePlugins/DotNetPack.psm1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/CorePlugins/DotNetTest.psm1 b/utils/Release-Package/CorePlugins/DotNetTest.psm1 deleted file mode 100644 index 7759fc0..0000000 --- a/utils/Release-Package/CorePlugins/DotNetTest.psm1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/CorePlugins/QualityGate.psm1 b/utils/Release-Package/CorePlugins/QualityGate.psm1 deleted file mode 100644 index 450a468..0000000 --- a/utils/Release-Package/CorePlugins/QualityGate.psm1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/DotNetProjectSupport.psm1 b/utils/Release-Package/DotNetProjectSupport.psm1 deleted file mode 100644 index a510eb5..0000000 --- a/utils/Release-Package/DotNetProjectSupport.psm1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/EngineSupport.psm1 b/utils/Release-Package/EngineSupport.psm1 deleted file mode 100644 index c3d29f2..0000000 --- a/utils/Release-Package/EngineSupport.psm1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/Release-Package.bat b/utils/Release-Package/Release-Package.bat deleted file mode 100644 index 6a4aba8..0000000 --- a/utils/Release-Package/Release-Package.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1" -pause diff --git a/utils/Release-Package/Release-Package.ps1 b/utils/Release-Package/Release-Package.ps1 deleted file mode 100644 index 8cef2be..0000000 --- a/utils/Release-Package/Release-Package.ps1 +++ /dev/null @@ -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 diff --git a/utils/Release-Package/scriptsettings.json b/utils/Release-Package/scriptsettings.json deleted file mode 100644 index 3161423..0000000 --- a/utils/Release-Package/scriptsettings.json +++ /dev/null @@ -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'])." - } - } -} diff --git a/utils/TestRunner.psm1 b/utils/TestRunner.psm1 deleted file mode 100644 index f382b24..0000000 --- a/utils/TestRunner.psm1 +++ /dev/null @@ -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 diff --git a/utils/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat new file mode 100644 index 0000000..048e3fb --- /dev/null +++ b/utils/Update-RepoUtils.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %* +pause diff --git a/utils/Update-RepoUtils/Update-RepoUtils.bat b/utils/Update-RepoUtils/Update-RepoUtils.bat deleted file mode 100644 index 8ff94ac..0000000 --- a/utils/Update-RepoUtils/Update-RepoUtils.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1" -pause diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 new file mode 100644 index 0000000..caf880e --- /dev/null +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -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 +} diff --git a/utils/Release-Package/CustomPlugins/.gitkeep b/utils/engines/release/custom/.gitkeep similarity index 100% rename from utils/Release-Package/CustomPlugins/.gitkeep rename to utils/engines/release/custom/.gitkeep diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json new file mode 100644 index 0000000..f89a19b --- /dev/null +++ b/utils/engines/release/scriptSettings.json @@ -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 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." + } + } +} diff --git a/utils/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 new file mode 100644 index 0000000..f4da98e --- /dev/null +++ b/utils/engines/test/Invoke-TestEngine.ps1 @@ -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 +} diff --git a/utils/engines/test/custom/.gitkeep b/utils/engines/test/custom/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json new file mode 100644 index 0000000..ff236f6 --- /dev/null +++ b/utils/engines/test/scriptSettings.json @@ -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." + } + } +} diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 new file mode 100644 index 0000000..feb7afa --- /dev/null +++ b/utils/modules/ChangelogSupport.psm1 @@ -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 diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 new file mode 100644 index 0000000..9d397d1 --- /dev/null +++ b/utils/modules/Engine/EngineContext.psm1 @@ -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 ) + - 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 )." + 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 + + + diff --git a/utils/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 new file mode 100644 index 0000000..5c43ed7 --- /dev/null +++ b/utils/modules/Engine/Import-EngineModules.ps1 @@ -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 + } +} diff --git a/utils/Release-Package/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 similarity index 66% rename from utils/Release-Package/PluginSupport.psm1 rename to utils/modules/Engine/PluginSupport.psm1 index 326a16c..30371ea 100644 --- a/utils/Release-Package/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -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 diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 new file mode 100644 index 0000000..ffda8d7 --- /dev/null +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -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 diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 new file mode 100644 index 0000000..a90d03d --- /dev/null +++ b/utils/modules/Engine/TestSupport.psm1 @@ -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 diff --git a/utils/GitTools.psm1 b/utils/modules/GitTools.psm1 similarity index 90% rename from utils/GitTools.psm1 rename to utils/modules/GitTools.psm1 index 405f408..be56c94 100644 --- a/utils/GitTools.psm1 +++ b/utils/modules/GitTools.psm1 @@ -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 { diff --git a/utils/Logging.psm1 b/utils/modules/Logging.psm1 similarity index 100% rename from utils/Logging.psm1 rename to utils/modules/Logging.psm1 diff --git a/utils/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 similarity index 93% rename from utils/ScriptConfig.psm1 rename to utils/modules/ScriptConfig.psm1 index 738cd5c..26bd953 100644 --- a/utils/ScriptConfig.psm1 +++ b/utils/modules/ScriptConfig.psm1 @@ -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 diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 new file mode 100644 index 0000000..de1a493 --- /dev/null +++ b/utils/modules/TestRunner.psm1 @@ -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 diff --git a/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 similarity index 83% rename from utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 rename to utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 index 43dc044..bbc7459 100644 --- a/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 +++ b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 @@ -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)) { diff --git a/utils/Release-Package/CorePlugins/CreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 similarity index 53% rename from utils/Release-Package/CorePlugins/CreateArchive.psm1 rename to utils/plugins/DotNet/DotNetCreateArchive.psm1 index 54cce44..92b34bd 100644 --- a/utils/Release-Package/CorePlugins/CreateArchive.psm1 +++ b/utils/plugins/DotNet/DotNetCreateArchive.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetDockerPush.psm1 b/utils/plugins/DotNet/DotNetDockerPush.psm1 new file mode 100644 index 0000000..6e6855a --- /dev/null +++ b/utils/plugins/DotNet/DotNetDockerPush.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetHelmPush.psm1 b/utils/plugins/DotNet/DotNetHelmPush.psm1 new file mode 100644 index 0000000..44532f0 --- /dev/null +++ b/utils/plugins/DotNet/DotNetHelmPush.psm1 @@ -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 diff --git a/utils/Release-Package/CorePlugins/NuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 similarity index 70% rename from utils/Release-Package/CorePlugins/NuGet.psm1 rename to utils/plugins/DotNet/DotNetNuGet.psm1 index 4dafc54..ecb2eb0 100644 --- a/utils/Release-Package/CorePlugins/NuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -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 + + + diff --git a/utils/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 new file mode 100644 index 0000000..bffc191 --- /dev/null +++ b/utils/plugins/DotNet/DotNetPack.psm1 @@ -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 diff --git a/utils/Release-Package/CorePlugins/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 similarity index 79% rename from utils/Release-Package/CorePlugins/DotNetPublish.psm1 rename to utils/plugins/DotNet/DotNetPublish.psm1 index 8acb8bc..84c4ec2 100644 --- a/utils/Release-Package/CorePlugins/DotNetPublish.psm1 +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 new file mode 100644 index 0000000..66ac1b6 --- /dev/null +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -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 + + diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 new file mode 100644 index 0000000..e888a4e --- /dev/null +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -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 diff --git a/utils/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1 new file mode 100644 index 0000000..7c43f52 --- /dev/null +++ b/utils/plugins/Npm/NpmBuild.psm1 @@ -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 diff --git a/utils/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1 new file mode 100644 index 0000000..82803c7 --- /dev/null +++ b/utils/plugins/Npm/NpmJestTest.psm1 @@ -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 diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 new file mode 100644 index 0000000..7ff8259 --- /dev/null +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -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 diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 new file mode 100644 index 0000000..3020c78 --- /dev/null +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -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 diff --git a/utils/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 new file mode 100644 index 0000000..36ba26a --- /dev/null +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -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 @" + + $label`: $value + + + + + + + + + + + + + + + $label + + $value + + +"@ +} + +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 diff --git a/utils/Release-Package/CorePlugins/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 similarity index 76% rename from utils/Release-Package/CorePlugins/GitHub.psm1 rename to utils/plugins/Platform/GitHub.psm1 index 38a9386..9af816c 100644 --- a/utils/Release-Package/CorePlugins/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -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) { diff --git a/utils/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1 new file mode 100644 index 0000000..da18d38 --- /dev/null +++ b/utils/plugins/Platform/QualityGate.psm1 @@ -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 diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 new file mode 100644 index 0000000..adfb262 --- /dev/null +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -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 diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 similarity index 93% rename from utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 rename to utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 index d338a36..c144f6a 100644 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +++ b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -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 diff --git a/utils/Force-AmendTaggedCommit/scriptsettings.json b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json similarity index 100% rename from utils/Force-AmendTaggedCommit/scriptsettings.json rename to utils/tools/Force-AmendTaggedCommit/scriptSettings.json diff --git a/utils/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 similarity index 94% rename from utils/Update-RepoUtils/Update-RepoUtils.ps1 rename to utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 index 410d078..80082c3 100644 --- a/utils/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -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 } diff --git a/utils/Update-RepoUtils/scriptsettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json similarity index 74% rename from utils/Update-RepoUtils/scriptsettings.json rename to utils/tools/Update-RepoUtils/scriptSettings.json index 568a597..de67aab 100644 --- a/utils/Update-RepoUtils/scriptsettings.json +++ b/utils/tools/Update-RepoUtils/scriptSettings.json @@ -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" ] } }