diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d3e7b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,118 @@ +# EditorConfig: https://editorconfig.org +# Shared code style for VS Code, Cursor, Visual Studio, Rider + +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space + +# ========================= +# C# +# ========================= +[*.cs] +indent_size = 2 + +# K&R braces (opening brace on same line) +csharp_new_line_before_open_brace = none + +# keep else/catch/finally on new line +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# indentation +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# spacing +csharp_space_after_cast = false +csharp_space_after_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false + +# Fluent / chained method calls (ReSharper compatibility) +resharper_csharp_continuous_indent_multiplier = 1 +resharper_continuous_indent_multiplier = 1 +resharper_csharp_align_multiline_calls_chain = false +resharper_align_multiline_calls_chain = false +resharper_csharp_outdent_dots = false +resharper_outdent_dots = false + +# Two blank lines between last using and namespace +resharper_csharp_blank_lines_between_using_and_namespace = 2 +resharper_blank_lines_between_using_and_namespace = 2 + +# Usings: System first; blank line between System and other groups (Roslyn) +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +resharper_sort_usings_system_first = true + +# Remove unnecessary using directives (dotnet format style --diagnostics IDE0005) +dotnet_diagnostic.IDE0005.severity = warning + +# File-scoped namespace declarations (IDE0160) +csharp_style_namespace_declarations = file_scoped:warning +dotnet_diagnostic.IDE0160.severity = warning + +# Omit braces on single-line if/else/for/foreach/while/using (IDE0011) +csharp_prefer_braces = when_multiline:none +dotnet_diagnostic.IDE0011.severity = none + +# Prefer 'var' over explicit type (IDE0008) +csharp_style_var_for_built_in_types = true:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = true:none +dotnet_diagnostic.IDE0008.severity = none + +# Prefer if-return over ternary for conditional returns (IDE0046) — multi-line guards stay readable +dotnet_style_prefer_conditional_expression_over_return = false:none +dotnet_diagnostic.IDE0046.severity = none + +# Expression-bodied members: prefer => over { return ...; } (IDE0022–IDE0027) +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_lambdas = true:none +csharp_style_expression_bodied_local_functions = false:none +csharp_style_expression_bodied_constructors = false:none +dotnet_diagnostic.IDE0022.severity = warning +dotnet_diagnostic.IDE0023.severity = warning +dotnet_diagnostic.IDE0024.severity = warning +dotnet_diagnostic.IDE0025.severity = warning +dotnet_diagnostic.IDE0026.severity = warning +dotnet_diagnostic.IDE0027.severity = warning + +# => on signature line; expression indented on next line (no blank line after =>) +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning +dotnet_diagnostic.IDE2006.severity = warning +resharper_csharp_wrap_before_arrow_with_expressions = false +resharper_wrap_before_arrow_with_expressions = false + + +# ========================= +# TypeScript / JavaScript +# ========================= +[*.{ts,tsx,js,jsx}] +indent_size = 2 + + +# ========================= +# JSON / YAML +# ========================= +[*.{json,yml,yaml}] +indent_size = 2 + + +# ========================= +# Markdown +# ========================= +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index cb4f22f..b5227d1 100644 --- a/.gitignore +++ b/.gitignore @@ -262,4 +262,8 @@ __pycache__/ *.pyc #Custom -![Uu]tils/** \ No newline at end of file +![Uu]tils/** + +# Generated test reports (RepoUtils) +test-results/ +utils/tests/results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0ddb9..627f4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## [1.0.6] - 2026-06-28 + +### Fixed +- Replaced `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` preview packages (`11.0.0-preview.5.26302.115`) that were incorrectly introduced in `1.0.5` with stable releases (`10.0.9`). + ## [1.0.5] - 2026-06-28 ### Changed diff --git a/README.md b/README.md index 97310bc..d68a466 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Reusable high-availability runtime coordination library for MaksIT services. -![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-57.1%25-yellowgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-49.1%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-70.7%25-green) ## Packages diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg deleted file mode 100644 index 8b8930b..0000000 --- a/assets/badges/coverage-branches.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Branch Coverage: 49.1% - - - - - - - - - - - - - - - Branch Coverage - - 49.1% - - diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg deleted file mode 100644 index d9d16ee..0000000 --- a/assets/badges/coverage-lines.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Line Coverage: 57.1% - - - - - - - - - - - - - - - Line Coverage - - 57.1% - - diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg deleted file mode 100644 index 32ded5d..0000000 --- a/assets/badges/coverage-methods.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Method Coverage: 70.7% - - - - - - - - - - - - - - - Method Coverage - - 70.7% - - diff --git a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj index 82250f4..f8c6894 100644 --- a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj +++ b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj @@ -13,8 +13,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all diff --git a/src/MaksIT.HAMode/MaksIT.HAMode.csproj b/src/MaksIT.HAMode/MaksIT.HAMode.csproj index f2bccce..4f64f4e 100644 --- a/src/MaksIT.HAMode/MaksIT.HAMode.csproj +++ b/src/MaksIT.HAMode/MaksIT.HAMode.csproj @@ -8,7 +8,7 @@ $(NoWarn);CS1591 MaksIT.HAMode - 1.0.5 + 1.0.6 Maksym Sadovnychyy MAKS-IT MaksIT.HAMode @@ -35,8 +35,8 @@ - - + + diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat index 85d776a..2255f3c 100644 --- a/utils/Invoke-ReleasePackage.bat +++ b/utils/Invoke-ReleasePackage.bat @@ -1,3 +1,4 @@ @echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* -pause +setlocal +pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat index 0cfd13f..7953777 100644 --- a/utils/Invoke-TestEngine.bat +++ b/utils/Invoke-TestEngine.bat @@ -1,3 +1,4 @@ @echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* -pause +setlocal +pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 index caf880e..4ad9cbc 100644 --- a/utils/engines/release/Invoke-ReleasePackage.ps1 +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -4,23 +4,62 @@ <# .SYNOPSIS Plugin-driven release engine entry script. + +.NOTES + Per-plugin dryRun on mutatesRemote plugins validates without remote mutation. + There is no engine-wide dryRun switch — set dryRun on each plugin as needed. #> -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +# Keep a plain param block so unbound launcher arguments remain in $args for +# optional Initialize-ReleaseExtension (advanced binding would reject them). +param() + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path . (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') Import-EngineModules -Engine Release -$settings = Get-ScriptSettings -ScriptDir $scriptDir +$releaseExtension = $null +if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) { + $releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList $args +} + +$settings = if ($null -ne $releaseExtension) { + $releaseExtension.Settings +} +else { + Get-ScriptSettings -ScriptDir $PSScriptRoot +} + $configuredPlugins = Get-ConfiguredPlugins -Settings $settings +$releaseBanner = if ($null -ne $releaseExtension) { + $releaseExtension.StepBanner +} +else { + 'RELEASE ENGINE' +} + Write-Log -Level 'STEP' -Message '==================================================' -Write-Log -Level 'STEP' -Message 'RELEASE ENGINE' +Write-Log -Level 'STEP' -Message $releaseBanner Write-Log -Level 'STEP' -Message '==================================================' $plugins = $configuredPlugins -$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings +$newContextParams = @{ + Plugins = $plugins + ScriptDir = $PSScriptRoot + SrcDir = $srcDir + Settings = $settings +} +if ($null -ne $releaseExtension) { + $newContextParams['ExtensionData'] = $releaseExtension.ContextData +} + +$engineContext = New-EngineContext @newContextParams + Write-Log -Level 'OK' -Message 'All pre-flight checks passed!' $sharedPluginSettings = $engineContext @@ -34,15 +73,14 @@ 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 + if ((Test-IsPublishPlugin -Plugin $plugin -EngineDirectory $PSScriptRoot) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -WriteLogs:$false) { + Initialize-ReleaseStageContext -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory $releaseStageInitialized = $true } } - $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false if (-not $pluginSucceeded) { $releaseHadPluginFailures = $true break @@ -52,7 +90,7 @@ else { 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 $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).' } Write-Log -Level 'OK' -Message '==================================================' diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index 2900473..3577558 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -1,7 +1,7 @@ { "$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).", + "description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DotNetNuGet/DockerImagePush/HelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).", "plugins": [ { "name": "DotNetReleaseVersion", @@ -37,7 +37,7 @@ "projectFiles": [ "..\\..\\..\\src\\MaksIT.HAMode\\MaksIT.HAMode.csproj" ], - "artifactsDir": "..\\..\\..\\release" + "artifactsDir": "..\\..\\..\\releases" }, { "name": "DotNetCreateArchive", @@ -63,7 +63,7 @@ "name": "GitHub", "stageLabel": "release", "enabled": true, - "githubToken": "GITHUB_MAKS_IT_COM", + "githubSecret": "GitHub", "repository": "https://github.com/MAKS-IT-COM/maksit-hamode", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseTitlePattern": "Release {version}" @@ -72,7 +72,7 @@ "name": "DotNetNuGet", "stageLabel": "release", "enabled": true, - "nugetApiKey": "NUGET_MAKS_IT", + "nugetSecret": "NuGet", "source": "https://api.nuget.org/v3/index.json" }, { @@ -94,7 +94,7 @@ "name": "NpmPublish", "stageLabel": "release", "enabled": false, - "npmApiKey": "NPMJS_MAKS_IT", + "npmSecret": "Npm", "registry": "https://registry.npmjs.org", "access": "public", "workspaceRoot": "..\\..\\..\\src", @@ -103,35 +103,6 @@ "@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", @@ -143,54 +114,6 @@ "*.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/scriptSettings.json b/utils/engines/test/scriptSettings.json index 5ef7a59..69cebd9 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -2,9 +2,6 @@ "$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", @@ -12,7 +9,8 @@ "enabled": true, "projects": [ "..\\..\\..\\src\\MaksIT.HAMode.Tests" - ] + ], + "resultsDir": "..\\..\\..\\test-results" }, { "name": "QualityGate", @@ -25,20 +23,18 @@ "name": "CoverageBadges", "stageLabel": "report", "enabled": true, - "badgesDir": "..\\..\\..\\assets\\badges", + "badgeFormat": "shields", + "readmePath": "..\\..\\..\\README.md", "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" } @@ -53,12 +49,8 @@ } } ], - "_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." - } + "paths": { + "readmePath": "..\\..\\..\\README.md", + "testResultsDir": "..\\..\\..\\test-results" } } diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 index 9d397d1..3b94fa9 100644 --- a/utils/modules/Engine/EngineContext.psm1 +++ b/utils/modules/Engine/EngineContext.psm1 @@ -3,12 +3,14 @@ <# .SYNOPSIS - Helpers to resolve engine semver and relative paths from plugin configuration. + Generic engine helpers: path resolution and the shared context facts API. .DESCRIPTION - Used by New-EngineContext and version plugins: - - DotNetReleaseVersion plugin -> projectFiles (.csproj ) - - NpmReleaseVersion plugin -> packageJsonPath (package.json version) + Engine-owned state stays on the context object (version, tag, skipPublishPlugins, …). + Plugin-published values go under $context.facts[namespace][name] via Set/Get/Test-EngineFact. + During migration, -LegacyProperty on Set dual-writes flat properties; Get falls back to them. + + Version plugins declare providesVersion = $true; New-EngineContext discovers the single enabled one. #> if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { @@ -18,6 +20,16 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } +$script:EngineStateAllowlist = @( + 'version' + 'tag' + 'skipPublishPlugins' + 'releaseDir' + 'artifactsDirectory' + 'deployMode' + 'orchestrator' +) + function Resolve-RelativePaths { param( [Parameter(Mandatory = $true)] @@ -51,175 +63,270 @@ function Resolve-RelativePaths { return @($resolved) } -function Get-CsprojPropertyValue { +function Initialize-EngineFactsBag { param( [Parameter(Mandatory = $true)] - [xml]$Csproj, - - [Parameter(Mandatory = $true)] - [string]$PropertyName + [psobject]$Context ) - # 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 (-not ($Context.PSObject.Properties.Name -contains 'facts') -or $null -eq $Context.facts) { + $Context | Add-Member -NotePropertyName facts -NotePropertyValue ([ordered]@{}) -Force + } +} - if ($propNode) { - return $propNode.$PropertyName +function Assert-EngineFactNamespace { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace + ) + + if ($Namespace -eq 'engine') { + throw "Namespace 'engine' is reserved; use Set-EngineState / Get-EngineState for engine-owned fields." + } + + if ($Namespace -cnotmatch '^[a-z][a-z0-9]*$') { + throw "Invalid facts namespace '$Namespace'. Use lowercase alphanumeric starting with a letter (e.g. 'test', 'dotnet')." + } +} + +function Assert-EngineFactName { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Name -cnotmatch '^[a-zA-Z][a-zA-Z0-9_]*$') { + throw "Invalid fact name '$Name'. Use letters, digits, underscore; must start with a letter." + } +} + +function Test-EngineFactValuePresent { + param( + [AllowNull()] + $Value + ) + + if ($null -eq $Value) { + return $false + } + + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + return $true +} + +function Set-EngineFact { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value, + + [ValidateSet('Error', 'Replace', 'Keep')] + [string]$Overwrite = 'Error', + + [Parameter(Mandatory = $false)] + [string]$LegacyProperty + ) + + Assert-EngineFactNamespace -Namespace $Namespace + Assert-EngineFactName -Name $Name + Initialize-EngineFactsBag -Context $Context + + if (-not $Context.facts.Contains($Namespace)) { + $Context.facts[$Namespace] = [ordered]@{} + } + + $bag = $Context.facts[$Namespace] + $exists = $bag.Contains($Name) + if ($exists) { + if ($Overwrite -eq 'Keep') { + if (-not [string]::IsNullOrWhiteSpace($LegacyProperty) -and -not ($Context.PSObject.Properties.Name -contains $LegacyProperty)) { + $Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $bag[$Name] -Force + } + return + } + + if ($Overwrite -eq 'Error') { + throw "Fact ${Namespace}.${Name} already set (use -Overwrite Replace or Keep)." + } + } + + $bag[$Name] = $Value + + if (-not [string]::IsNullOrWhiteSpace($LegacyProperty)) { + $Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $Value -Force + } +} + +function Get-EngineFact { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Default, + + [switch]$Required, + + [Parameter(Mandatory = $false)] + [string[]]$LegacyProperty + ) + + Assert-EngineFactNamespace -Namespace $Namespace + Assert-EngineFactName -Name $Name + Initialize-EngineFactsBag -Context $Context + + if ($Context.facts.Contains($Namespace) -and $Context.facts[$Namespace].Contains($Name)) { + $value = $Context.facts[$Namespace][$Name] + if (Test-EngineFactValuePresent -Value $value) { + return $value + } + } + + foreach ($propertyName in @($LegacyProperty)) { + if ([string]::IsNullOrWhiteSpace($propertyName)) { + continue + } + + if ($Context.PSObject.Properties.Name -contains $propertyName) { + $legacyValue = $Context.$propertyName + if (Test-EngineFactValuePresent -Value $legacyValue) { + return $legacyValue + } + } + } + + if ($Required) { + throw "Required fact ${Namespace}.${Name} is missing." + } + + if ($PSBoundParameters.ContainsKey('Default')) { + return $Default } return $null } -function Get-CsprojVersions { +function Test-EngineFact { param( [Parameter(Mandatory = $true)] - [string[]]$ProjectFiles + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + [string[]]$LegacyProperty ) - Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..." - $projectVersions = @{} + $value = Get-EngineFact -Context $Context -Namespace $Namespace -Name $Name -LegacyProperty $LegacyProperty + return (Test-EngineFactValuePresent -Value $value) +} - foreach ($projectPath in $ProjectFiles) { - if (-not (Test-Path $projectPath -PathType Leaf)) { - Write-Error "Project file not found at: $projectPath" - exit 1 +function Set-EngineState { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value + ) + + if ($script:EngineStateAllowlist -notcontains $Name) { + throw "Engine state key '$Name' is not allowlisted. Use Set-EngineFact for plugin outputs, or extend the engine allowlist for documented engine fields." + } + + $Context | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force +} + +function Add-EnginePublishCompletion { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Publisher + ) + + if ([string]::IsNullOrWhiteSpace($Publisher)) { + throw "Publisher name is required." + } + + $completedBy = @(Get-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Default @()) + if ($completedBy -notcontains $Publisher) { + $completedBy += $Publisher + } + + Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Value $completedBy -Overwrite Replace + Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completed' -Value $true -Overwrite Replace -LegacyProperty 'publishCompleted' +} + +function Get-EngineState { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Default, + + [switch]$Required + ) + + if ($script:EngineStateAllowlist -notcontains $Name) { + throw "Engine state key '$Name' is not allowlisted." + } + + if ($Context.PSObject.Properties.Name -contains $Name) { + $value = $Context.$Name + if (Test-EngineFactValuePresent -Value $value) { + return $value } - - 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 + if ($Required) { + throw "Required engine state '$Name' is missing." + } + + if ($PSBoundParameters.ContainsKey('Default')) { + return $Default + } + + return $null } -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 - - - +Export-ModuleMember -Function ` + Resolve-RelativePaths, ` + Initialize-EngineFactsBag, ` + Set-EngineFact, ` + Get-EngineFact, ` + Test-EngineFact, ` + Set-EngineState, ` + Add-EnginePublishCompletion, ` + Get-EngineState diff --git a/utils/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 index 5c43ed7..a557a58 100644 --- a/utils/modules/Engine/Import-EngineModules.ps1 +++ b/utils/modules/Engine/Import-EngineModules.ps1 @@ -32,4 +32,9 @@ function Import-EngineModules { Import-Module $modulePath -Force } + + $extensionImport = Join-Path $modulesDir 'Extensions' 'Import-ExtensionModules.ps1' + if (Test-Path -LiteralPath $extensionImport -PathType Leaf) { + . $extensionImport + } } diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 index 30371ea..8d8c482 100644 --- a/utils/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -16,6 +16,36 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } +function Test-IsEngineRuntimeModuleName { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName + ) + + # Engine runtime under modules/ only — never dual-homed under plugins/. + $engineNames = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@( + 'ChangelogSupport', + 'ExternalCommandSupport', + 'GitTools', + 'Logging', + 'ScriptConfig', + 'TestRunner', + 'EngineContext', + 'PluginSupport', + 'ReleaseSupport', + 'TestSupport', + 'DeployConfig', + 'EngineContextSupport', + 'OrchestratorSupport', + 'PluginPathSupport' + ), + [System.StringComparer]::OrdinalIgnoreCase + ) + + return $engineNames.Contains($ModuleName) +} + function Import-PluginDependency { param( [Parameter(Mandatory = $true)] @@ -31,13 +61,29 @@ function Import-PluginDependency { $modulesDir = Get-RepoUtilsModulesDirectory $engineModuleDir = $PSScriptRoot - $modulePath = Join-Path $modulesDir "$ModuleName.psm1" - if (-not (Test-Path $modulePath -PathType Leaf)) { - $modulePath = Join-Path $engineModuleDir "$ModuleName.psm1" + $srcDir = Get-RepoUtilsSrcDirectory + $pluginsRoot = Join-Path $srcDir 'plugins' + $candidatePaths = [System.Collections.Generic.List[string]]::new() + + if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) { + # Engine runtime: modules/ only (no plugins/ fallback). + $candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1")) + $candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1")) + $extensionsDir = Join-Path $modulesDir 'Extensions' + $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) + } + else { + # Plugin helpers: plugins/ only (no modules/ legacy shadow). + foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1")) + } } - if (Test-Path $modulePath -PathType Leaf) { - Import-Module $modulePath -Force -Global -ErrorAction Stop + foreach ($modulePath in $candidatePaths) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global -ErrorAction Stop + break + } } if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { @@ -117,17 +163,277 @@ function Test-PluginAllowedOnBranch { return $allowedBranches -contains $CurrentBranch } -function Test-IsPublishPlugin { +function Get-PluginMetadataObject { param( [Parameter(Mandatory = $true)] - $Plugin + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $null + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $null + } + + return & $metadataCommand + } + catch { + return $null + } +} + +function Test-PluginCompatible { + <# + .SYNOPSIS + Applies an optional compatibility policy supplied by an extension. + #> + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + $extensionTest = Get-Command Test-ExtensionPluginCompatibility -ErrorAction SilentlyContinue + if ($extensionTest) { + return & $extensionTest @PSBoundParameters + } + + return $true +} + +function Test-PluginMutatesRemote { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory ) if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { return $false } - return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name) + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) { + $EngineDirectory = [string]$Plugin.context.scriptDir + } + elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) { + $EngineDirectory = [string]$Plugin.scriptDir + } + } + + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + return $false + } + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $false + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $false + } + + $metadata = & $metadataCommand + if ($null -eq $metadata) { + return $false + } + + if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') { + return [bool]$metadata.mutatesRemote + } + } + catch { + return $false + } + + return $false +} + +function Get-SecretEnvironmentValue { + <# + .SYNOPSIS + Reads a secret value from an environment variable by logical name. + + .DESCRIPTION + Plugins never store secret material in scriptSettings.json. Settings hold a + logical name (e.g. "GitHub", "NuGet"); the process environment variable with + that same name must be set before the engine runs. + + .PARAMETER Name + Logical secret name — also the environment variable name to read. + + .OUTPUTS + System.String. The environment variable value, or $null when unset. + + .EXAMPLE + $token = Get-SecretEnvironmentValue -Name 'GitHub' + #> + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return [Environment]::GetEnvironmentVariable($Name) +} + +function Resolve-PluginSecretName { + <# + .SYNOPSIS + Resolves a logical secret name from a plugin's scriptSettings entry. + + .DESCRIPTION + Reads a string property such as githubSecret / nugetSecret / npmSecret / + containerRegistrySecret from the plugin settings object. Returns $null when + the property is missing or blank. + + .PARAMETER PluginSettings + Plugin settings object from scriptSettings.json (the enabled plugin entry). + + .PARAMETER PropertyName + Settings property that holds the logical secret name (e.g. 'githubSecret'). + + .OUTPUTS + System.String. Trimmed logical secret name, or $null. + + .EXAMPLE + $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret' + $key = Get-SecretEnvironmentValue -Name $name + #> + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $value = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value.Trim() + } + } + + return $null +} + +function Get-RegistryCredentialsFromRuntime { + <# + .SYNOPSIS + Loads container-registry username/password from a logical secret name. + + .DESCRIPTION + Looks up the environment variable named by SecretName. The value must be + Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login + and image-pull secret creation — never pass the password itself as a parameter. + + .PARAMETER SecretName + Logical secret name (environment variable name), not a password or token. + + .PARAMETER SharedSettings + Optional engine shared context (reserved for callers that thread context). + + .OUTPUTS + Hashtable with User and Password keys (decoded credential material). + + .EXAMPLE + $creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry' + # $creds.User / $creds.Password + #> + # SecretName is a logical env-var name from scriptSettings, not a password value. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'SecretName', + Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.' + )] + param( + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings + ) + + $raw = Get-SecretEnvironmentValue -Name $SecretName + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "Environment variable '$SecretName' is not set." + } + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) + } + catch { + throw "Failed to decode '$SecretName' 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 '$SecretName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Resolve-EngineDirectoryFromSharedSettings { + param( + [Parameter(Mandatory = $true)] + $SharedSettings + ) + + if ($SharedSettings.PSObject.Properties.Name -contains 'engineScriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.engineScriptDir)) { + return [string]$SharedSettings.engineScriptDir + } + + return [string]$SharedSettings.scriptDir +} + +function Test-PluginSkipsRemoteMutation { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $engineDirectory = Resolve-EngineDirectoryFromSharedSettings -SharedSettings $SharedSettings + if (-not (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory)) { + return $false + } + + return ($Plugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $Plugin.dryRun -and [bool]$Plugin.dryRun) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory + ) + + return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory } function Get-PluginSettingValue { @@ -263,12 +569,31 @@ function Resolve-PluginModulePath { $srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent $pluginsRoot = Join-Path $srcDir "plugins" $pluginFileName = "{0}.psm1" -f $Plugin.name - $candidatePaths = @( - (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) - ) + $candidatePaths = [System.Collections.Generic.List[string]]::new() + $candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName)) + + $preferredGroups = @('Platform', 'DotNet', 'Npm') + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName)) + + if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) { + foreach ($extensionPath in Get-ExtensionPluginModulePaths -PluginsRoot $pluginsRoot -PluginFileName $pluginFileName) { + $candidatePaths.Add($extensionPath) + } + } + + foreach ($group in $preferredGroups[1..($preferredGroups.Count - 1)]) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) $pluginFileName)) + } + + $reservedPluginDirs = @($preferredGroups + @('Shared')) + if (Test-Path -LiteralPath $pluginsRoot -PathType Container) { + Get-ChildItem -LiteralPath $pluginsRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notin $reservedPluginDirs } | + Sort-Object Name | + ForEach-Object { + $candidatePaths.Add((Join-Path $_.FullName $pluginFileName)) + } + } foreach ($candidatePath in $candidatePaths) { if (Test-Path $candidatePath -PathType Leaf) { @@ -360,11 +685,34 @@ function Invoke-ConfiguredPlugin { return $true } + $metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory + if ($null -ne $metadata -and ($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) { + $versionAlreadySet = $false + if (Get-Command Get-EngineState -ErrorAction SilentlyContinue) { + $existingVersion = Get-EngineState -Context $SharedSettings -Name 'version' -ErrorAction SilentlyContinue + $versionAlreadySet = -not [string]::IsNullOrWhiteSpace([string]$existingVersion) + } + elseif (($SharedSettings.PSObject.Properties.Name -contains 'version') -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.version)) { + $versionAlreadySet = $true + } + + if ($versionAlreadySet) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (version already resolved during New-EngineContext)." + return $true + } + + # Test engine (and other hosts) may not resolve version in New-EngineContext; run the plugin now. + } + 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 } + if (-not (Test-PluginCompatible -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + return $true + } + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." @@ -383,4 +731,4 @@ function 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 +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, 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 index ffda8d7..0de88e4 100644 --- a/utils/modules/Engine/ReleaseSupport.psm1 +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -24,13 +24,101 @@ if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -n } } -if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) { +if (-not (Get-Command Resolve-RelativePaths -ErrorAction SilentlyContinue) -or -not (Get-Command Set-EngineState -ErrorAction SilentlyContinue)) { $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" if (Test-Path $engineContextModulePath -PathType Leaf) { Import-Module $engineContextModulePath -Force } } +function Get-EnabledVersionPlugins { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $versionPlugins = @() + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace([string]$plugin.name)) { + continue + } + + if (-not $plugin.enabled) { + continue + } + + $metadata = Get-PluginMetadataObject -Plugin $plugin -EngineDirectory $ScriptDir + if ($null -eq $metadata) { + continue + } + + if (($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) { + $versionPlugins += $plugin + } + } + + return @($versionPlugins) +} + +function Invoke-VersionPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + throw "Version plugin '$($Plugin.name)' module not found at: $modulePath" + } + + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $Context + & $invokeCommand -Settings $pluginSettings +} + +function Resolve-EngineContextVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $versionPlugins = @(Get-EnabledVersionPlugins -Plugins $Plugins -ScriptDir $ScriptDir) + if ($versionPlugins.Count -eq 0) { + throw "Configure exactly one enabled release version plugin (declares providesVersion in Get-PluginMetadata), e.g. DotNetReleaseVersion (projectFiles), NpmReleaseVersion (packageJsonPath), or FileReleaseVersion (versionFilePath)." + } + + if ($versionPlugins.Count -gt 1) { + $names = ($versionPlugins | ForEach-Object { [string]$_.name }) -join ', ' + throw "Configure only one enabled release version plugin. Found: $names." + } + + $versionPlugin = $versionPlugins[0] + Invoke-VersionPlugin -Plugin $versionPlugin -Context $Context -EngineDirectory $ScriptDir + + $version = Get-EngineState -Context $Context -Name 'version' + if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version)) { + throw "Version plugin '$($versionPlugin.name)' did not set a version on the engine context." + } + + return [string]$versionPlugin.name +} + function Assert-WorkingTreeClean { $gitStatus = Get-GitStatusShort if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) { @@ -48,21 +136,20 @@ function Assert-WorkingTreeClean { function Initialize-ReleaseStageContext { param( - [Parameter(Mandatory = $true)] - [object[]]$RemainingPlugins, - [Parameter(Mandatory = $true)] [psobject]$SharedSettings, [Parameter(Mandatory = $true)] - [string]$ArtifactsDirectory, - - [Parameter(Mandatory = $true)] - [string]$Version + [string]$ArtifactsDirectory ) if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) { - $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $SharedSettings -Name 'releaseDir' -Value $ArtifactsDirectory + } + else { + $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force + } } } @@ -78,13 +165,13 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [Parameter(Mandatory = $false)] + [psobject]$ExtensionData ) - $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir - $version = $resolvedVersion.version - $versionSource = $resolvedVersion.source - $releaseRelative = '..\..\..\release' + $releaseRelative = '..\..\..\releases' $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative)) $currentBranch = Get-CurrentBranch @@ -111,28 +198,58 @@ function New-EngineContext { $releaseBranches = @('main') } - $isReleaseBranch = $releaseBranches -contains $currentBranch - $isNonReleaseBranch = -not $isReleaseBranch + $isNonReleaseBranch = -not ($releaseBranches -contains $currentBranch) 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]@{ + $context = [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 + facts = [ordered]@{} } + + $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir + $version = [string](Get-EngineState -Context $context -Name 'version' -Required) + $tag = "v$version" + Set-EngineState -Context $context -Name 'tag' -Value $tag + Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." + + $dryRunPlugins = @( + $Plugins | + Where-Object { + $_.enabled -and + ($_.PSObject.Properties.Name -contains 'dryRun') -and + $null -ne $_.dryRun -and + [bool]$_.dryRun -and + (Test-PluginMutatesRemote -Plugin $_ -EngineDirectory $ScriptDir) + } | + ForEach-Object { [string]$_.name } + ) + if ($dryRunPlugins.Count -gt 0) { + Write-Log -Level "INFO" -Message " Plugin dryRun (validate only): $($dryRunPlugins -join ', ')" + } + + $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue + if ($expandContext) { + $expandParams = @{ + Context = $context + ScriptDir = $ScriptDir + Settings = $Settings + } + if ($PSBoundParameters.ContainsKey('ExtensionData')) { + $expandParams['ExtensionData'] = $ExtensionData + } + + return & $expandContext @expandParams + } + + return $context } function Get-PreferredReleaseBranch { diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 index a90d03d..b49b60b 100644 --- a/utils/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -10,6 +10,13 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } +if (-not (Get-Command Initialize-EngineFactsBag -ErrorAction SilentlyContinue)) { + $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" + if (Test-Path $engineContextModulePath -PathType Leaf) { + Import-Module $engineContextModulePath -Force + } +} + function New-EngineContext { param( [Parameter(Mandatory = $true)] @@ -19,20 +26,34 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [Parameter(Mandatory = $false)] + [psobject]$ExtensionData ) - $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]@{ + $context = [pscustomobject]@{ scriptDir = $ScriptDir srcDir = $SrcDir utilsDir = $SrcDir - badgesDir = $badgesDir + facts = [ordered]@{} } + + $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue + if ($expandContext) { + $expandParams = @{ + Context = $context + ScriptDir = $ScriptDir + Settings = $Settings + } + if ($PSBoundParameters.ContainsKey('ExtensionData')) { + $expandParams['ExtensionData'] = $ExtensionData + } + + return & $expandContext @expandParams + } + + return $context } Export-ModuleMember -Function New-EngineContext diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 new file mode 100644 index 0000000..b6c05f1 --- /dev/null +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -0,0 +1,102 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$script:ExternalCommandTestHandler = $null +$script:ExternalCommandAvailability = @{} + +function Set-ExternalCommandTestHandler { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Handler + ) + + $script:ExternalCommandTestHandler = $Handler +} + +function Clear-ExternalCommandTestHandler { + $script:ExternalCommandTestHandler = $null +} + +function Set-ExternalCommandAvailability { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Availability + ) + + $script:ExternalCommandAvailability = @{} + foreach ($key in $Availability.Keys) { + $script:ExternalCommandAvailability[[string]$key] = [bool]$Availability[$key] + } +} + +function Invoke-ExternalCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$ArgumentList = @(), + + [string]$WorkingDirectory, + + [string]$InputObject, + + [switch]$MergeErrorOutput + ) + + $previousLocation = $null + if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) { + $previousLocation = Get-Location + Push-Location $WorkingDirectory + } + + try { + $effectiveWorkingDirectory = (Get-Location).Path + + if ($null -ne $script:ExternalCommandTestHandler) { + $handlerResult = & $script:ExternalCommandTestHandler ` + -Name $Name ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $effectiveWorkingDirectory ` + -InputObject $InputObject ` + -MergeErrorOutput:$MergeErrorOutput.IsPresent + + $global:LASTEXITCODE = [int]$handlerResult.ExitCode + if ($null -eq $handlerResult.Output) { + return @() + } + + if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { + return @($handlerResult.Output) + } + + return @([string]$handlerResult.Output) + } + + if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) { + throw "External command '$Name' is marked unavailable." + } + + if (-not [string]::IsNullOrWhiteSpace($InputObject)) { + $output = $InputObject | & $Name @ArgumentList 2>&1 + } + elseif ($MergeErrorOutput) { + $output = & $Name @ArgumentList 2>&1 + } + else { + $output = & $Name @ArgumentList + } + + return @($output) + } + finally { + if ($null -ne $previousLocation) { + Pop-Location + } + } +} + +Export-ModuleMember -Function ` + Invoke-ExternalCommand, ` + Set-ExternalCommandTestHandler, ` + Clear-ExternalCommandTestHandler, ` + Set-ExternalCommandAvailability diff --git a/utils/modules/GitTools.psm1 b/utils/modules/GitTools.psm1 index 6a246eb..e13050c 100644 --- a/utils/modules/GitTools.psm1 +++ b/utils/modules/GitTools.psm1 @@ -51,7 +51,36 @@ function Invoke-GitInternal { [string]$ErrorMessage = "Git command failed" ) + if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path $PSScriptRoot -Parent + $externalCandidates = @( + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), + (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + ) + foreach ($externalModule in $externalCandidates) { + if (Test-Path -LiteralPath $externalModule -PathType Leaf) { + Import-Module $externalModule -Global + break + } + } + } + if ($CaptureOutput) { + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + $output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } + + if ($null -eq $output) { + return "" + } + + return ($output -join "`n").Trim() + } + $output = & git @Arguments 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { @@ -66,7 +95,13 @@ function Invoke-GitInternal { return ($output -join "`n").Trim() } - & git @Arguments + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null + } + else { + & git @Arguments + } + $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { Write-Error "$ErrorMessage (exit code: $exitCode)" @@ -82,16 +117,7 @@ function Invoke-GitInternal { function Get-CurrentBranch { Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..." - $branch = & git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$branch) -or [string]$branch -eq 'HEAD') { - # Handle unborn branches (no commits yet) where rev-parse can fail. - $branch = & git symbolic-ref --short HEAD 2>$null - } - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$branch)) { - Write-Error "Could not determine current branch (repository may be detached or not initialized)." - exit 1 - } - $branch = [string]$branch + $branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch" Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch" return $branch } diff --git a/utils/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 index 26bd953..3cdbb35 100644 --- a/utils/modules/ScriptConfig.psm1 +++ b/utils/modules/ScriptConfig.psm1 @@ -2,33 +2,33 @@ #requires -PSEdition Core function Get-ScriptSettings { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ScriptDir, [Parameter(Mandatory = $false)] - [string]$SettingsFileName = "scriptSettings.json" + [string]$SettingsFileName = 'scriptSettings.json' ) $settingsPath = Join-Path $ScriptDir $SettingsFileName - if (-not (Test-Path $settingsPath -PathType Leaf)) { - Write-Error "Settings file not found: $settingsPath" - exit 1 + if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { + throw "Settings file not found: $settingsPath" } - return Get-Content $settingsPath -Raw | ConvertFrom-Json + return Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } function Assert-Command { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Command ) if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { - Write-Error "Required command '$Command' is missing. Aborting." - exit 1 + throw "Required command '$Command' is missing. Aborting." } } diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 index de1a493..b1935e2 100644 --- a/utils/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -14,6 +14,28 @@ Usage: pwsh -Command "Import-Module .\TestRunner.psm1" #> +function Import-ExternalCommandSupportInternal { + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + return + } + + $srcDir = Split-Path $PSScriptRoot -Parent + $candidates = @( + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), + (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + ) + foreach ($modulePath in $candidates) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global + break + } + } + + if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { + throw "ExternalCommandSupport module not found. Expected: $($candidates -join ', ')" + } +} + function Import-LoggingModuleInternal { if (Get-Command Write-Log -ErrorAction SilentlyContinue) { return @@ -96,8 +118,8 @@ function Invoke-TestsWithCoverage { $ErrorActionPreference = "Stop" - # Normalize to a non-empty list of absolute .csproj paths. - $resolvedProjectFiles = [System.Collections.Generic.List[string]]::new() + # Normalize to a non-empty list of absolute working directories (folder containing the test project). + $resolvedProjectDirs = [System.Collections.Generic.List[string]]::new() foreach ($raw in $TestProjectPath) { if ([string]::IsNullOrWhiteSpace($raw)) { continue } $full = [System.IO.Path]::GetFullPath($raw.Trim()) @@ -107,46 +129,22 @@ function Invoke-TestsWithCoverage { 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) + $dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName } + if ($resolvedProjectDirs -notcontains $dir) { + [void]$resolvedProjectDirs.Add($dir) } } - if ($resolvedProjectFiles.Count -eq 0) { + if ($resolvedProjectDirs.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" + $ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults" } else { $ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory) @@ -160,53 +158,39 @@ function Invoke-TestsWithCoverage { 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" + foreach ($d in $resolvedProjectDirs) { + Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d" } } - $verbosity = if ($Silent) { 'quiet' } else { 'normal' } + foreach ($TestProjectDir in $resolvedProjectDirs) { + Push-Location $TestProjectDir + try { + $dotnetArgs = @( + "test" + "--collect:XPlat Code Coverage" + "--results-directory", $ResultsDir + "--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 - } + Import-ExternalCommandSupportInternal + if ($Silent) { + $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput + } + else { + Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default + } - if ($LASTEXITCODE -ne 0) { - return [PSCustomObject]@{ - Success = $false - Error = "Build failed for '$projectFile' with exit code $LASTEXITCODE" + $testExitCode = $LASTEXITCODE + if ($testExitCode -ne 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode" + } } } - } - - 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" - } + finally { + Pop-Location } } @@ -370,11 +354,12 @@ function Invoke-NpmJestTestsWithCoverage { Push-Location $workspaceFull try { $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') + Import-ExternalCommandSupportInternal if ($Silent) { - $null = & npm @npmArgs 2>&1 + $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput } else { - & npm @npmArgs + Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default } if ($LASTEXITCODE -ne 0) { @@ -428,4 +413,272 @@ function Invoke-NpmJestTestsWithCoverage { } } -Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage +function Get-NpmCoverageFromSummaryFile { + param( + [Parameter(Mandatory = $true)] + [string]$SummaryPath, + + [switch]$Silent + ) + + if (-not (Test-Path -LiteralPath $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" + } + } + + return [PSCustomObject]@{ + Success = $true + 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 + CoverageFormat = 'npm' + CoverageSummaryFile = $SummaryPath + } +} + +function Get-DotNetCoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [switch]$Silent + ) + + $coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($coverageFiles.Count -eq 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $ResultsDirectory" + } + } + + $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 { + $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 }) + + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageFile = ($coveragePaths -join ';') + CoverageFiles = $coveragePaths + ResultsDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + CoverageFormat = 'dotnet' + } +} + +function Get-CoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [ValidateSet('auto', 'dotnet', 'npm')] + [string]$Format = 'auto', + + [switch]$Silent + ) + + $resolvedDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + if (-not (Test-Path -LiteralPath $resolvedDirectory -PathType Container)) { + return [PSCustomObject]@{ + Success = $false + Error = "Results directory not found: $resolvedDirectory" + } + } + + $hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + $jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json' + $hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf + + $effectiveFormat = $Format + if ($effectiveFormat -eq 'auto') { + if ($hasDotNet -and -not $hasNpm) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm -and -not $hasDotNet) { + $effectiveFormat = 'npm' + } + elseif ($hasDotNet) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm) { + $effectiveFormat = 'npm' + } + else { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $resolvedDirectory" + } + } + } + + if ($effectiveFormat -eq 'dotnet') { + return Get-DotNetCoverageFromResultsDirectory -ResultsDirectory $resolvedDirectory -Silent:$Silent + } + + if ($effectiveFormat -eq 'npm') { + $npmResult = Get-NpmCoverageFromSummaryFile -SummaryPath $jestSummary -Silent:$Silent + if (-not $npmResult.Success) { + return $npmResult + } + + $npmResult | Add-Member -NotePropertyName ResultsDirectory -NotePropertyValue $resolvedDirectory -Force + return $npmResult + } + + return [PSCustomObject]@{ + Success = $false + Error = "Unsupported coverage format '$Format'." + } +} + +function Publish-CoverageMetricsToSharedContext { + param( + [Parameter(Mandatory = $true)] + $SharedSettings, + + [Parameter(Mandatory = $true)] + $TestResult + ) + + if (-not (Get-Command Set-EngineFact -ErrorAction SilentlyContinue)) { + $engineContextPath = Join-Path $PSScriptRoot 'Engine' 'EngineContext.psm1' + if (Test-Path -LiteralPath $engineContextPath -PathType Leaf) { + Import-Module $engineContextPath -Force -Global + } + } + + if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResult' -Value $TestResult -Overwrite Replace -LegacyProperty 'testResult' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageLineRate' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'coverageLineRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'qualityLineCoverage' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'qualityLineCoverage' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageBranchRate' -Value $TestResult.BranchRate -Overwrite Replace -LegacyProperty 'coverageBranchRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageMethodRate' -Value $TestResult.MethodRate -Overwrite Replace -LegacyProperty 'coverageMethodRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageTotalMethods' -Value $TestResult.TotalMethods -Overwrite Replace -LegacyProperty 'coverageTotalMethods' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoveredMethods' -Value $TestResult.CoveredMethods -Overwrite Replace -LegacyProperty 'coverageCoveredMethods' + + if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageFormat' -Value $TestResult.CoverageFormat -Overwrite Replace -LegacyProperty 'coverageFormat' + } + + if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResultsDirectory' -Value $TestResult.ResultsDirectory -Overwrite Replace -LegacyProperty 'testResultsDirectory' + } + + if ($TestResult.CoverageFiles) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoberturaPaths' -Value @($TestResult.CoverageFiles) -Overwrite Replace -LegacyProperty 'coverageCoberturaPaths' + } + + return + } + + $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 'CoverageFormat') { + $SharedSettings | Add-Member -NotePropertyName coverageFormat -NotePropertyValue $TestResult.CoverageFormat -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 + } +} + +Export-ModuleMember -Function ` + Invoke-TestsWithCoverage, ` + Invoke-NpmJestTestsWithCoverage, ` + Get-NpmCoverageFromSummaryFile, ` + Get-DotNetCoverageFromResultsDirectory, ` + Get-CoverageFromResultsDirectory, ` + Publish-CoverageMetricsToSharedContext diff --git a/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 new file mode 100644 index 0000000..f2f0ed6 --- /dev/null +++ b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 @@ -0,0 +1,69 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Discovers existing .NET NuGet package artifacts. + +.DESCRIPTION + Scans artifactsDir for .nupkg/.snupkg matching the release version and populates shared + context (packageFile, symbolsPackageFile, releaseArchiveInputs) 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 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts' + Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Set-EngineFact' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + + if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + } + else { + $artifactsDirectory = $sharedSettings.artifactsDirectory + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw 'DiscoverDotNetPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.' + } + + Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..." + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version + + Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" + } + else { + Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version." + } + + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetArtifactSupport.psm1 b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 new file mode 100644 index 0000000..205406e --- /dev/null +++ b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 @@ -0,0 +1,63 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Shared helpers for discovering .NET NuGet package artifacts on disk. +#> + +function Resolve-DotNetPackageArtifacts { + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + if (-not (Test-Path $ArtifactsDirectory -PathType Container)) { + throw "Artifacts directory not found: $ArtifactsDirectory" + } + + $packageFile = $null + $newestNupkgWrite = [datetime]::MinValue + $nupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -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: $ArtifactsDirectory" + } + + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = $null + $newestSnupkgWrite = [datetime]::MinValue + $snupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.snupkg' + foreach ($candidate in $snupkgCandidates) { + if ($candidate.Name -like "*$Version*.snupkg") { + if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { + $newestSnupkgWrite = $candidate.LastWriteTime + $symbolsPackageFile = $candidate + } + } + } + + if ($symbolsPackageFile) { + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + + return [pscustomobject]@{ + PackageFile = $packageFile + SymbolsPackageFile = $symbolsPackageFile + ReleaseArchiveInputs = $releaseArchiveInputs + } +} + +Export-ModuleMember -Function Resolve-DotNetPackageArtifacts diff --git a/utils/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 index 92b34bd..e921189 100644 --- a/utils/plugins/DotNet/DotNetCreateArchive.psm1 +++ b/utils/plugins/DotNet/DotNetCreateArchive.psm1 @@ -26,6 +26,7 @@ function Invoke-Plugin { ) Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context @@ -33,13 +34,18 @@ function Invoke-Plugin { $version = $sharedSettings.version $archiveInputs = @() - if ($sharedSettings.PSObject.Properties['releaseArchiveInputs'] -and $sharedSettings.releaseArchiveInputs) { - $archiveInputs = @($sharedSettings.releaseArchiveInputs) + $fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs') + if ($null -ne $fromFact) { + $archiveInputs = @($fromFact) } - elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { - $archiveInputs = @($sharedSettings.packageFile.FullName) - if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { - $archiveInputs += $sharedSettings.symbolsPackageFile.FullName + else { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -ne $packageFile) { + $archiveInputs = @($packageFile.FullName) + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $archiveInputs += $symbolsPackageFile.FullName + } } } @@ -79,16 +85,18 @@ 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 + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -ne $packageFile) { + $releaseAssetPaths += $packageFile.FullName } - if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { - $releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $releaseAssetPaths += $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 + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' } Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetDockerPush.psm1 b/utils/plugins/DotNet/DotNetDockerPush.psm1 deleted file mode 100644 index 6e6855a..0000000 --- a/utils/plugins/DotNet/DotNetDockerPush.psm1 +++ /dev/null @@ -1,245 +0,0 @@ -#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 deleted file mode 100644 index 44532f0..0000000 --- a/utils/plugins/DotNet/DotNetHelmPush.psm1 +++ /dev/null @@ -1,181 +0,0 @@ -#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/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 index ecb2eb0..ef08a81 100644 --- a/utils/plugins/DotNet/DotNetNuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -29,22 +29,36 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' $packageFile = $sharedSettings.packageFile + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + Assert-Command dotnet if (-not $packageFile) { throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet." } - if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { - throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json." + if ($dryRun) { + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource" + return } - $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { - throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + if ([string]::IsNullOrWhiteSpace($nugetSecret)) { + throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." + } + + $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret + if ([string]::IsNullOrWhiteSpace($nugetKey)) { + throw "NuGet API key is not set. Set environment variable '$nugetSecret'." } $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { @@ -55,17 +69,22 @@ function Invoke-Plugin { } Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." - dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate if ($LASTEXITCODE -ne 0) { 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 + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" + Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'DotNetNuGet' } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 index 8e5164a..5929045 100644 --- a/utils/plugins/DotNet/DotNetPack.psm1 +++ b/utils/plugins/DotNet/DotNetPack.psm1 @@ -30,7 +30,8 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" + Import-PluginDependency -ModuleName "DotNetArtifactSupport" -RequiredCommand "Resolve-DotNetPackageArtifacts" $sharedSettings = $Settings.context $scriptDir = $sharedSettings.scriptDir @@ -48,13 +49,12 @@ function Invoke-Plugin { if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory } else { $artifactsDirectory = $sharedSettings.artifactsDirectory } - $releaseArchiveInputs = [System.Collections.Generic.List[string]]::new() - $packageFiles = [System.Collections.Generic.List[object]]::new() - $symbolsPackageFiles = [System.Collections.Generic.List[object]]::new() Assert-Command dotnet @@ -68,88 +68,31 @@ function Invoke-Plugin { New-Item -ItemType Directory -Path $outputDir | Out-Null } - foreach ($packageProjectPath in @($projectFiles)) { - Write-Log -Level "STEP" -Message "Packing NuGet package: $([System.IO.Path]::GetFileName($packageProjectPath))" - $packStartedAt = Get-Date - $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." - } - - # Prefer files produced by this pack invocation; fallback to newest matching version. - $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")) { - $isFromThisPack = $candidate.LastWriteTime -ge $packStartedAt.AddSeconds(-2) - if ($isFromThisPack -and $candidate.LastWriteTime -gt $newestNupkgWrite) { - $newestNupkgWrite = $candidate.LastWriteTime - $packageFile = $candidate - } - } - } - if (-not $packageFile) { - 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)" - [void]$packageFiles.Add($packageFile) - [void]$releaseArchiveInputs.Add($packageFile.FullName) - - $symbolsPackageFile = $null - $newestSnupkgWrite = [datetime]::MinValue - $snupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.snupkg" - foreach ($candidate in $snupkgCandidates) { - if ($candidate.Name -like "*$version*.snupkg") { - $isFromThisPack = $candidate.LastWriteTime -ge $packStartedAt.AddSeconds(-2) - if ($isFromThisPack -and $candidate.LastWriteTime -gt $newestSnupkgWrite) { - $newestSnupkgWrite = $candidate.LastWriteTime - $symbolsPackageFile = $candidate - } - } - } - if (-not $symbolsPackageFile) { - 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)" - [void]$symbolsPackageFiles.Add($symbolsPackageFile) - [void]$releaseArchiveInputs.Add($symbolsPackageFile.FullName) - } - else { - Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." - } + # 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." } - $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue (@($packageFiles)[0]) -Force - $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue (@($symbolsPackageFiles)[0]) -Force - $sharedSettings | Add-Member -NotePropertyName packageFiles -NotePropertyValue @($packageFiles) -Force - $sharedSettings | Add-Member -NotePropertyName symbolsPackageFiles -NotePropertyValue @($symbolsPackageFiles) -Force - $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue @($releaseArchiveInputs) -Force + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $outputDir -Version $version + + Write-Log -Level "OK" -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" + } + else { + Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." + } + + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' } Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 index 84c4ec2..d2dacba 100644 --- a/utils/plugins/DotNet/DotNetPublish.psm1 +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -27,18 +27,21 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" $sharedSettings = $Settings.context - $projectFiles = $sharedSettings.projectFiles + $projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles') $artifactsDirectory = $sharedSettings.artifactsDirectory $publishProjectPath = $null Assert-Command dotnet - if (-not $sharedSettings.PSObject.Properties['projectFiles'] -or $projectFiles.Count -eq 0) { + if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) { throw "DotNetPublish plugin requires project files in the shared context." } + $projectFiles = @($projectFiles) + if (!(Test-Path $artifactsDirectory)) { New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null } @@ -64,9 +67,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 + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' } Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 index 66ac1b6..3cb01fd 100644 --- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -3,11 +3,13 @@ <# .SYNOPSIS - Loads release version into shared context. + Loads release version from an SDK-style .csproj into shared context. .DESCRIPTION - Dedicated version-loading plugin. It reads .csproj version via - EngineContext helpers and writes Version into the shared runtime context. + Dedicated version-loading plugin. Reads from the first configured + projectFiles entry and writes it (plus the resolved projectFiles) to the + shared runtime context. Declares providesVersion = $true so the engine can + discover it as the single release version source. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -18,6 +20,56 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { } } +function Get-CsprojPropertyValueInternal { + 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-CsprojVersionInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectPath + ) + + if (-not (Test-Path $ProjectPath -PathType Leaf)) { + throw "DotNetReleaseVersion: project file not found at '$ProjectPath'." + } + + if ([System.IO.Path]::GetExtension($ProjectPath) -ne ".csproj") { + throw "DotNetReleaseVersion: configured project file is not a .csproj file: '$ProjectPath'." + } + + [xml]$csproj = Get-Content $ProjectPath + $version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version" + + if ([string]::IsNullOrWhiteSpace([string]$version)) { + throw "DotNetReleaseVersion: not found in '$ProjectPath'." + } + + return [string]$version +} + +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + function Invoke-Plugin { param( [Parameter(Mandatory = $true)] @@ -25,17 +77,20 @@ function Invoke-Plugin { ) Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-DotNetReleaseVersion" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" $shared = $Settings.context - $resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir) + if ($projectFiles.Count -eq 0) { + throw "DotNetReleaseVersion plugin requires 'projectFiles' (first .csproj with ) in scriptSettings.json." + } - $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)" + Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..." + $version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0] + + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles' + Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $version" } -Export-ModuleMember -Function Invoke-Plugin - - +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 index e888a4e..b8bd09d 100644 --- a/utils/plugins/DotNet/DotNetTest.psm1 +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -10,8 +10,9 @@ 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. + those keys generically (not tied to this plugin by name). When `resultsDir` (or the + multi-project default TestResults folder) is used, Cobertura output is kept on disk + via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -37,75 +38,15 @@ function Invoke-Plugin { $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) - } + $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim()))) } } if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) { - $resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$pluginSettings.project) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths - if ($resolvedPath) { - $testProjectPaths.Add($resolvedPath) - } + $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project))) } if ($testProjectPaths.Count -eq 0) { throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json." @@ -128,6 +69,7 @@ function Invoke-Plugin { } if ($testResultsDir) { $invokeTestParams.ResultsDirectory = $testResultsDir + $invokeTestParams.KeepResults = $true } $testResult = Invoke-TestsWithCoverage @invokeTestParams @@ -136,19 +78,8 @@ function Invoke-Plugin { 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 - } + Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult Write-Log -Level "OK" -Message " All tests passed!" Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" diff --git a/utils/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1 index 82803c7..e89f604 100644 --- a/utils/plugins/Npm/NpmJestTest.psm1 +++ b/utils/plugins/Npm/NpmJestTest.psm1 @@ -58,20 +58,12 @@ function Invoke-Plugin { 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 - } + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" + Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" + Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot' + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) { - $sharedSettings | Add-Member -NotePropertyName coverageSummaryFile -NotePropertyValue $testResult.CoverageSummaryFile -Force + Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile' } Write-Log -Level "OK" -Message " All tests passed!" diff --git a/utils/plugins/Npm/NpmPack.psm1 b/utils/plugins/Npm/NpmPack.psm1 new file mode 100644 index 0000000..7ec9945 --- /dev/null +++ b/utils/plugins/Npm/NpmPack.psm1 @@ -0,0 +1,139 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Packs npm workspace packages into .tgz release artifacts. + +.DESCRIPTION + Runs npm pack for each configured workspace package and publishes the + resulting tarball paths into shared release context (releaseAssetPaths) + for the GitHub release plugin. +#> + +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 "Set-EngineFact" + + $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 "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $artifactsDirectory = $null + if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir))) + Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory + } + elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) { + $artifactsDirectory = [string]$shared.artifactsDirectory + } + else { + throw "NpmPack plugin requires release-stage artifactsDirectory." + } + + $packOrder = @() + if ($pluginSettings.publishOrder) { + if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { + $packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) { + $packOrder = @([string]$pluginSettings.publishOrder) + } + } + elseif ($pluginSettings.packOrder) { + if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) { + $packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) { + $packOrder = @([string]$pluginSettings.packOrder) + } + } + + if ($packOrder.Count -eq 0) { + throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)." + } + + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $packOrder.Count -gt 1) { + throw "NpmPack plugin requires npm workspaces when packing more than one package." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $releaseAssetPaths = @() + + Push-Location $workspaceRoot + try { + foreach ($packageName in $packOrder) { + Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..." + if ($useWorkspaces) { + $tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + $tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) { + throw "npm pack failed for '$packageName'." + } + + $tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim() + if (-not (Test-Path $tarballPath -PathType Leaf)) { + throw "Could not locate pack output for '$packageName' at: $tarballPath" + } + + $strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim() + if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) { + Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue + } + + Write-Log -Level "OK" -Message " Package ready: $tarballPath" + $releaseAssetPaths += $tarballPath + } + } + finally { + Pop-Location + } + + Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory + Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' + if ($releaseAssetPaths.Count -gt 0) { + $packageItem = Get-Item -LiteralPath $releaseAssetPaths[0] + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile' + } + + Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmPackageSupport.psm1 b/utils/plugins/Npm/NpmPackageSupport.psm1 new file mode 100644 index 0000000..4b3a903 --- /dev/null +++ b/utils/plugins/Npm/NpmPackageSupport.psm1 @@ -0,0 +1,62 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-NpmRootPackageJson { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $packageJsonPath = Join-Path $WorkspaceRoot 'package.json' + if (-not (Test-Path $packageJsonPath -PathType Leaf)) { + throw "package.json not found at '$packageJsonPath'." + } + + return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Test-NpmWorkspacesConfigured { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) { + return $false + } + + $workspaces = $json.workspaces + if ($workspaces -is [string]) { + return -not [string]::IsNullOrWhiteSpace($workspaces) + } + + if ($workspaces -is [System.Collections.IEnumerable]) { + $entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + return $entries.Count -gt 0 + } + + return $false +} + +function Assert-NpmRootPackageName { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + + [Parameter(Mandatory = $true)] + [string]$ExpectedPackageName + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + $rootPackageName = [string]$json.name + if ([string]::IsNullOrWhiteSpace($rootPackageName)) { + throw "Root package.json at '$WorkspaceRoot' is missing 'name'." + } + + if ($rootPackageName -ne $ExpectedPackageName) { + throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)." + } +} + +Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index 7ff8259..db6b0ec 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,9 +6,9 @@ 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. + Publishes packages in configured order using npmSecret (logical secret name). + Pass the token via an environment variable named like the configured npm secret (e.g. Npm). + Uses a temporary .npmrc in the workspace root. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -32,18 +32,10 @@ function Invoke-Plugin { $pluginSettings = $Settings $shared = $Settings.context + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + 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) @@ -84,11 +76,34 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { + throw "NpmPublish plugin requires npm workspaces when publishing more than one package." + } + + if ($dryRun) { + foreach ($packageName in $publishOrder) { + Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry" + } + return + } + + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' + if ([string]::IsNullOrWhiteSpace($npmSecret)) { + throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)." + } + + $npmToken = Get-SecretEnvironmentValue -Name $npmSecret + if ([string]::IsNullOrWhiteSpace($npmToken)) { + throw "npm API key is not set. Set environment variable '$npmSecret'." + } + $registryHost = ([uri]$registry).Host $tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp" $npmRcContent = @" registry=$registry -//$registryHost/:_authToken=$npmApiKey +//$registryHost/:_authToken=$npmToken "@ Push-Location $workspaceRoot @@ -97,7 +112,14 @@ registry=$registry foreach ($packageName in $publishOrder) { Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." - npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + if ($useWorkspaces) { + npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + npm publish --access $access --userconfig $tempNpmRcPath + } + if ($LASTEXITCODE -ne 0) { throw "Failed to publish npm package '$packageName'." } @@ -105,7 +127,8 @@ registry=$registry } Write-Log -Level "OK" -Message " npm publish completed." - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" + Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish' } finally { if (Test-Path $tempNpmRcPath -PathType Leaf) { @@ -115,4 +138,8 @@ registry=$registry } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 index 3020c78..ad48e46 100644 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -57,6 +57,10 @@ function Set-PackageJsonVersionInternal { ($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline } +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + function Invoke-Plugin { param( [Parameter(Mandatory = $true)] @@ -64,7 +68,7 @@ function Invoke-Plugin { ) Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" $pluginSettings = $Settings $shared = $Settings.context @@ -90,10 +94,11 @@ function Invoke-Plugin { 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 + $npmWorkspaceRoot = Split-Path -Parent $packageJsonPath + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot' + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath' Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version" } -Export-ModuleMember -Function Invoke-Plugin +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/CleanupArtifacts.psm1 b/utils/plugins/Platform/CleanupArtifacts.psm1 new file mode 100644 index 0000000..ab145fa --- /dev/null +++ b/utils/plugins/Platform/CleanupArtifacts.psm1 @@ -0,0 +1,122 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Artifact cleanup plugin — remove files from the artifacts directory after release. + +.DESCRIPTION + Removes files from the configured artifacts directory using glob patterns. + When includePatterns is omitted, defaults to NuGet outputs (*.nupkg, *.snupkg). + Typically placed at the end of the Release stage after archive or publish 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 Get-CleanupPatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @('*.nupkg', '*.snupkg') + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @('*.nupkg', '*.snupkg') + } + + return @([string]$ConfiguredPatterns) +} + +function Get-ExcludePatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @() + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @() + } + + return @([string]$ConfiguredPatterns) +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $artifactsDirectory = $sharedSettings.artifactsDirectory + $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns + $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CleanupArtifacts plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" + return + } + + Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." + + $itemsToRemove = @() + foreach ($pattern in $patterns) { + $matchedItems = @( + Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } + ) + + if ($excludePatterns.Count -gt 0) { + $matchedItems = @( + $matchedItems | + Where-Object { + $item = $_ + -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) + } + ) + } + + $itemsToRemove += @($matchedItems) + } + + $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) + + if ($itemsToRemove.Count -eq 0) { + Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." + return + } + + foreach ($item in $itemsToRemove) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Removed: $($item.Name)" + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 index 36ba26a..2187b9c 100644 --- a/utils/plugins/Platform/CoverageBadges.psm1 +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -6,7 +6,11 @@ Coverage badge plugin for the test engine. .DESCRIPTION - Reads line/branch/method coverage from shared engine context and writes SVG badges. + Reads line/branch/method coverage from shared engine context. + + badgeFormat "svg" (default): writes SVG files under badgesDir. + badgeFormat "shields": updates readmePath (string or array) with img.shields.io markdown + (no local assets). #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -80,6 +84,80 @@ function New-BadgeSvgInternal { "@ } +function New-ShieldsIoBadgeUrlInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $labelToken = ($Label -replace ' ', '%20') + $valueToken = "$Percentage%25" + return "https://img.shields.io/badge/$labelToken-$valueToken-$Color" +} + +function New-ShieldsIoBadgeMarkdownInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color + return "![$Label]($url)" +} + +function Update-ReadmeShieldsBadgesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReadmePath, + + [Parameter(Mandatory = $true)] + [object[]]$Badges, + + [Parameter(Mandatory = $true)] + [hashtable]$Metrics, + + [Parameter(Mandatory = $true)] + [psobject]$Thresholds + ) + + if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) { + throw "CoverageBadges readmePath not found: $ReadmePath" + } + + $content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8 + + foreach ($badge in @($Badges)) { + $metricValue = $Metrics[[string]$badge.metric] + if ($null -eq $metricValue) { + throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'." + } + + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds + $markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color + # Horizontal whitespace only; optional CR for CRLF. Do not let \s* eat the following blank line. + $pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)[^\S\r\n]*\r?$" + if ($content -notmatch $pattern) { + throw "README badge line not found for label '$($badge.label)' in: $ReadmePath" + } + + $content = [regex]::Replace($content, $pattern, $markdown) + } + + $content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline +} + function Get-CoverageMetricsFromSharedContext { param( [Parameter(Mandatory = $true)] @@ -131,17 +209,9 @@ function Invoke-Plugin { $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 + $badgeFormat = 'svg' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) { + $badgeFormat = [string]$pluginSettings.badgeFormat } $thresholds = $pluginSettings.colorThresholds @@ -158,10 +228,56 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Generating coverage badges..." + if ($badgeFormat -eq 'shields') { + $readmePathSetting = $null + if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) { + $readmePathSetting = $sharedSettings.readmePath + } + if ($pluginSettings.PSObject.Properties.Name -contains 'readmePath' -and $pluginSettings.readmePath) { + $readmePathSetting = $pluginSettings.readmePath + } + + $readmePaths = @() + if ($null -ne $readmePathSetting) { + $readmePaths = @(Resolve-RelativePaths -Value $readmePathSetting -BasePath $scriptDir) + } + if ($readmePaths.Count -eq 0) { + throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json." + } + + foreach ($readmePath in $readmePaths) { + Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds + Write-Log -Level "OK" -Message "README shields updated: $readmePath" + } + + foreach ($badge in @($pluginSettings.badges)) { + $metricValue = $metrics[[string]$badge.metric] + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds + Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)" + } + + Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs." + return + } + + $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 + } + 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)'." + $badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label } + throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'." } $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 new file mode 100644 index 0000000..c7bb3bd --- /dev/null +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -0,0 +1,81 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version from a repo-root VERSION file into shared context. + +.DESCRIPTION + Reads a single-line semver from the configured versionFilePath (default + repo-root VERSION). Useful for repositories without .csproj or package.json + version metadata. Declares providesVersion = $true so the engine can + discover it as the single release version source. +#> + +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-VersionFileSemverInternal { + param( + [Parameter(Mandatory = $true)] + [string]$VersionFilePath + ) + + if (-not (Test-Path $VersionFilePath -PathType Leaf)) { + throw "FileReleaseVersion: VERSION file not found at '$VersionFilePath'." + } + + $version = (Get-Content -Path $VersionFilePath -Raw -Encoding UTF8).Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + throw "FileReleaseVersion: VERSION file is empty at '$VersionFilePath'." + } + + $version = $version -replace '^[vV]', '' + if ($version -notmatch '^\d+\.\d+\.\d+') { + throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." + } + + return $version +} + +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" + + $shared = $Settings.context + $versionFileSetting = if ($Settings.versionFilePath) { + $Settings.versionFilePath + } + else { + '..\..\..\VERSION' + } + + $versionFilePaths = @(Resolve-RelativePaths -Value $versionFileSetting -BasePath $shared.scriptDir) + if ($versionFilePaths.Count -eq 0) { + throw "FileReleaseVersion plugin requires 'versionFilePath' (repo-root VERSION file) in scriptSettings.json." + } + + $versionFilePath = $versionFilePaths[0] + Write-Log -Level "INFO" -Message "Reading version from VERSION file (versionFilePath)..." + $version = Get-VersionFileSemverInternal -VersionFilePath $versionFilePath + + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'release' -Name 'versionFilePath' -Value $versionFilePath -Overwrite Replace -LegacyProperty 'versionFilePath' + Write-Log -Level "OK" -Message " Release version loaded by FileReleaseVersion plugin: $version" +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 9af816c..61773e0 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -95,10 +95,11 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubTokenEnvVar = $pluginSettings.githubToken + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -108,15 +109,37 @@ function Invoke-Plugin { $releaseDir = $sharedSettings.releaseDir $releaseAssetPaths = @() - Assert-Command gh + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings - if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { - throw "GitHub plugin requires 'githubToken' in scriptSettings.json." + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." } - $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) - if ([string]::IsNullOrWhiteSpace($githubToken)) { - throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($dryRun) { + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" + return + } + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubSecret)) { + throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)." + } + + $ghToken = Get-SecretEnvironmentValue -Name $githubSecret + if ([string]::IsNullOrWhiteSpace($ghToken)) { + throw "GitHub token is not set. Set environment variable '$githubSecret'." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -126,7 +149,26 @@ function Invoke-Plugin { $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version - if ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { + if (Get-Command Get-EngineFact -ErrorAction SilentlyContinue) { + $fromAssets = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -LegacyProperty @('releaseAssetPaths') + if ($null -ne $fromAssets) { + $releaseAssetPaths = @($fromAssets) + } + else { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -eq $packageFile) { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -LegacyProperty @('packageFile') + } + if ($null -ne $packageFile) { + $releaseAssetPaths = @($packageFile.FullName) + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $releaseAssetPaths += $symbolsPackageFile.FullName + } + } + } + } + elseif ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { $releaseAssetPaths = @($sharedSettings.releaseAssetPaths) } elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { @@ -163,7 +205,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " GitHub title: $releaseName" $previousGhToken = $env:GH_TOKEN - $env:GH_TOKEN = $githubToken + $env:GH_TOKEN = $ghToken try { $ghVersion = & gh --version 2>&1 @@ -171,7 +213,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" } - Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + Write-Log -Level "INFO" -Message " Auth secret: $githubSecret" $authArgs = @("api", "repos/$repo", "--jq", ".full_name") $authOutput = & gh @authArgs 2>&1 @@ -188,7 +230,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" @@ -209,6 +251,9 @@ function Invoke-Plugin { $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) try { + if (-not [string]::IsNullOrWhiteSpace($releaseDir) -and -not (Test-Path -LiteralPath $releaseDir -PathType Container)) { + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + } [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( @@ -229,7 +274,7 @@ function Invoke-Plugin { } Write-Log -Level "OK" -Message " GitHub release created successfully." - $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'GitHub' } finally { if ($null -ne $previousGhToken) { @@ -241,4 +286,8 @@ function Invoke-Plugin { } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/PesterTest.psm1 b/utils/plugins/Platform/PesterTest.psm1 new file mode 100644 index 0000000..1dbc1e4 --- /dev/null +++ b/utils/plugins/Platform/PesterTest.psm1 @@ -0,0 +1,238 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Pester test plugin for the RepoUtils test engine. + +.DESCRIPTION + Runs the community Pester suite and publishes normalized coverage metrics on the + shared engine context for 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 Get-JaCoCoCoverageMetrics { + param( + [Parameter(Mandatory = $true)] + [string]$ReportPath + ) + + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "JaCoCo coverage report not found at: $ReportPath" + } + } + + [xml]$reportXml = Get-Content -LiteralPath $ReportPath -Raw -Encoding UTF8 + $reportNode = $reportXml.report + if ($null -eq $reportNode) { + return [PSCustomObject]@{ + Success = $false + Error = "Invalid JaCoCo report (missing root ): $ReportPath" + } + } + + function Get-CounterRate { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0.0 + } + + $missed = [long]$counter.missed + $covered = [long]$counter.covered + $total = $missed + $covered + if ($total -le 0) { + return 0.0 + } + + return [math]::Round(($covered / $total) * 100, 1) + } + + function Get-CounterTotal { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.missed + [long]$counter.covered + } + + function Get-CounterCovered { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.covered + } + + return [PSCustomObject]@{ + Success = $true + LineRate = Get-CounterRate -CounterType 'LINE' + BranchRate = Get-CounterRate -CounterType 'BRANCH' + MethodRate = Get-CounterRate -CounterType 'METHOD' + TotalMethods = Get-CounterTotal -CounterType 'METHOD' + CoveredMethods = Get-CounterCovered -CounterType 'METHOD' + CoverageFile = $ReportPath + CoverageFiles = @($ReportPath) + } +} + +function Import-PesterModuleIfNeeded { + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 })) { + Write-Log -Level 'INFO' -Message 'Installing Pester 5...' + Install-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Scope CurrentUser -Force -SkipPublisherCheck + } + + Import-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Force +} + +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 + + if (-not $pluginSettings.testsDir) { + throw "PesterTest plugin requires 'testsDir' in scriptSettings.json." + } + + $testsDirs = @(Resolve-RelativePaths -Value $pluginSettings.testsDir -BasePath $scriptDir) + $testsDir = $testsDirs[0] + + $configFileSetting = 'pester.config.ps1' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.configFile)) { + $configFileSetting = [string]$pluginSettings.configFile + } + + $configPaths = @(Resolve-RelativePaths -Value $configFileSetting -BasePath $scriptDir) + $configPath = $configPaths[0] + + if (-not (Test-Path -LiteralPath $testsDir -PathType Container)) { + throw "Pester tests directory not found at: $testsDir" + } + + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Pester config not found at: $configPath" + } + + Import-PesterModuleIfNeeded + + Write-Log -Level 'STEP' -Message 'Running Pester tests...' + Write-Log -Level 'INFO' -Message " Tests: $testsDir" + Write-Log -Level 'INFO' -Message " Config: $configPath" + + Push-Location $testsDir + try { + $config = & $configPath + if ($null -eq $config) { + throw "Pester config did not return a configuration object: $configPath" + } + + $config.Run.PassThru = $true + $config.Run.Exit = $false + + $resultsDirSetting = [string]$pluginSettings.resultsDir + if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) { + $resultsDirSetting = '..\..\..\test-results' + } + $resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $resultsDirSetting)) + New-Item -ItemType Directory -Path $resultsDirectory -Force | Out-Null + + # Point Pester report outputs at the resolved results dir (overrides pester.config relative paths). + if ($null -ne $config.TestResult -and $config.TestResult.Enabled) { + $config.TestResult.OutputPath = Join-Path $resultsDirectory 'pester-results.xml' + } + if ($null -ne $config.CodeCoverage -and $config.CodeCoverage.Enabled) { + $config.CodeCoverage.OutputPath = Join-Path $resultsDirectory 'coverage.xml' + } + + Write-Log -Level 'INFO' -Message " Results: $resultsDirectory" + + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { + throw "Pester tests failed: $($result.FailedCount) failed, $($result.PassedCount) passed, $($result.SkippedCount) skipped." + } + + $coveragePath = Join-Path $resultsDirectory 'coverage.xml' + $coverageMetrics = $null + if (Test-Path -LiteralPath $coveragePath -PathType Leaf) { + $coverageMetrics = Get-JaCoCoCoverageMetrics -ReportPath $coveragePath + if (-not $coverageMetrics.Success) { + throw $coverageMetrics.Error + } + } + else { + $coverageMetrics = [PSCustomObject]@{ + Success = $true + LineRate = 0.0 + BranchRate = 0.0 + MethodRate = 0.0 + TotalMethods = 0 + CoveredMethods = 0 + CoverageFile = $null + CoverageFiles = @() + } + } + + $testResult = [PSCustomObject]@{ + Success = $true + LineRate = $coverageMetrics.LineRate + BranchRate = $coverageMetrics.BranchRate + MethodRate = $coverageMetrics.MethodRate + TotalMethods = $coverageMetrics.TotalMethods + CoveredMethods = $coverageMetrics.CoveredMethods + CoverageFile = $coverageMetrics.CoverageFile + CoverageFiles = @($coverageMetrics.CoverageFiles) + ResultsDirectory = $resultsDirectory + PesterResult = $result + } + + Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext' + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult + + Write-Log -Level 'OK' -Message " All tests passed! ($($result.PassedCount) 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)%" + } + finally { + Pop-Location + } +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata, Get-JaCoCoCoverageMetrics diff --git a/utils/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1 index da18d38..a81dfde 100644 --- a/utils/plugins/Platform/QualityGate.psm1 +++ b/utils/plugins/Platform/QualityGate.psm1 @@ -10,9 +10,9 @@ 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) + - facts test.coverageLineRate (via Get-EngineFact) + - legacy qualityLineCoverage / coverageLineRate flat properties + - testResult.LineRate (object from a test plugin) Configure coverageThreshold > 0 to require one of those inputs. With coverageThreshold 0 and scanVulnerabilities false, the plugin is a no-op. @@ -64,6 +64,20 @@ function Get-LineCoveragePercentFromSharedContext { $Shared ) + if (Get-Command Get-EngineFact -ErrorAction SilentlyContinue) { + $fromFact = Get-EngineFact -Context $Shared -Namespace 'test' -Name 'coverageLineRate' -LegacyProperty @('qualityLineCoverage', 'coverageLineRate') + if ($null -ne $fromFact -and -not [string]::IsNullOrWhiteSpace([string]$fromFact)) { + return [double]$fromFact + } + + $testResult = Get-EngineFact -Context $Shared -Namespace 'test' -Name 'testResult' -LegacyProperty @('testResult') + if ($null -ne $testResult -and ($testResult.PSObject.Properties.Name -contains 'LineRate')) { + return [double]$testResult.LineRate + } + + return $null + } + foreach ($prop in @('qualityLineCoverage', 'coverageLineRate')) { if ($Shared.PSObject.Properties.Name -contains $prop) { $raw = $Shared.$prop @@ -92,7 +106,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context @@ -107,11 +121,9 @@ function Invoke-Plugin { 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 = @() + $fromContext = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles') + $projectFiles = if ($null -ne $fromContext) { @($fromContext) } else { @() } } $coverageThreshold = 0 @@ -129,7 +141,7 @@ function Invoke-Plugin { 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)." + throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set test.coverageLineRate (or legacy qualityLineCoverage / coverageLineRate / testResult.LineRate) from an earlier plugin." } Write-Log -Level "STEP" -Message "Checking line coverage threshold against shared context..." diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index adfb262..43b6dfe 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -3,7 +3,7 @@ <# .SYNOPSIS - Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish). + Central gate before remote-mutation plugins (see each plugin's Get-PluginMetadata). .DESCRIPTION Place this plugin immediately before any publish plugins in scriptSettings.json. It sets @@ -50,10 +50,15 @@ function Invoke-NotMetInternal { [string]$Reason ) - $Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $Shared -Name 'skipPublishPlugins' -Value $true + } + else { + $Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force + } + if ($When -eq 'fail') { - Write-Log -Level "ERROR" -Message "ReleasePublishGuard: $Reason" - exit 1 + throw "ReleasePublishGuard: $Reason" } Write-Log -Level "WARN" -Message " Publish suppressed: $Reason" @@ -67,6 +72,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "PluginSupport" -RequiredCommand "Get-PluginBranches" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" @@ -82,7 +88,7 @@ function Invoke-Plugin { throw "ReleasePublishGuard: whenRequirementsNotMet must be 'skip' or 'fail'." } - $shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $false -Force + Set-EngineState -Context $shared -Name 'skipPublishPlugins' -Value $false Write-Log -Level "STEP" -Message "Release publish guard..." @@ -138,7 +144,7 @@ function Invoke-Plugin { return } - $shared | Add-Member -NotePropertyName tag -NotePropertyValue $tag -Force + Set-EngineState -Context $shared -Name 'tag' -Value $tag } $ensureRemote = $true diff --git a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 index 80082c3..00b3ce0 100644 --- a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -8,8 +8,7 @@ .DESCRIPTION This script clones the configured repository into a temporary directory, refreshes the parent directory of this script, preserves existing - scriptSettings.json files in subfolders, and copies the cloned source - contents into that parent directory. + scriptSettings.json files in subfolders, and copies the cloned source contents into that parent directory. All configuration is stored in scriptSettings.json. @@ -23,7 +22,9 @@ - repository.sourceSubdirectory: Folder copied into the target directory - repository.preserveFileName: Existing file name to preserve in subfolders - repository.cloneDepth: Depth used for git clone - - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh + - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh (preserve dest) + - repository.omittedRelativeDirectories: Relative directories to delete from dest and never copy from source + (product repos: ["tests"] — RepoUtils self-tests stay only in maksit-repoutils / enterprise) #> [CmdletBinding()] @@ -88,6 +89,56 @@ function Test-IsInRelativeDirectory { return $false } +function Get-ReleaseDeployPreserveFiles { + param( + [Parameter(Mandatory = $true)] + [string]$TargetDirectory + ) + + return @() +} + +function Add-PreservedFileBackup { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IList]$PreservedFiles, + + [Parameter(Mandatory = $true)] + [System.IO.FileInfo]$File, + + [Parameter(Mandatory = $true)] + [string]$TargetDirectory, + + [Parameter(Mandatory = $true)] + [string]$TemporaryRoot, + + [Parameter(Mandatory = $true)] + [bool]$DryRun + ) + + $relativePath = [System.IO.Path]::GetRelativePath($TargetDirectory, $File.FullName) + foreach ($existing in $PreservedFiles) { + if ($existing.RelativePath.Equals($relativePath, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $backupPath = Join-Path $TemporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $PreservedFiles.Add([pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + }) | Out-Null + + if (-not $DryRun) { + $backupDirectory = Split-Path -Parent $backupPath + if (-not (Test-Path -Path $backupDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null + } + + Copy-Item -Path $File.FullName -Destination $backupPath -Force + } +} + #region Import Modules $scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1" @@ -134,6 +185,18 @@ else { [System.IO.Path]::Combine('engines', 'test', 'custom') ) } +[string[]]$omittedRelativeDirectories = if ($settings.repository.omittedRelativeDirectories) { + @( + $settings.repository.omittedRelativeDirectories | + ForEach-Object { + ConvertTo-NormalizedRelativePath -Path ([string]$_) + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) +} +else { + @() +} #endregion @@ -235,32 +298,39 @@ try { } } - $preservedFiles = @() + $preservedFiles = [System.Collections.ArrayList]@() [string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue if ($existingPreservedFiles) { foreach ($file in $existingPreservedFiles) { - $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) - $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) - $preservedFiles += [pscustomobject]@{ - RelativePath = $relativePath - BackupPath = $backupPath - } - - if (-not $dryRun) { - Copy-Item -Path $file.FullName -Destination $backupPath -Force - } + Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun } - Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" + Write-Log -Level "OK" -Message "Preserved $($existingPreservedFiles.Count) existing $preserveFileName file(s)" } else { Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" } + $releaseDeployPreserveFiles = @(Get-ReleaseDeployPreserveFiles -TargetDirectory $targetDirectory) + if ($releaseDeployPreserveFiles.Count -gt 0) { + foreach ($file in $releaseDeployPreserveFiles) { + Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun + } + Write-Log -Level "OK" -Message "Preserved $($releaseDeployPreserveFiles.Count) release deploy file(s)" + } + + $preservedRelativePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($preservedFile in $preservedFiles) { + [void]$preservedRelativePaths.Add($preservedFile.RelativePath) + } + if ($dryRun) { Write-LogStep "Dry run summary" - Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName and release deploy files" Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + if ($omittedRelativeDirectories.Count -gt 0) { + Write-Log -Level "INFO" -Message "Would omit (delete dest + skip copy): $($omittedRelativeDirectories -join ', ')" + } Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" if ($preservedFiles.Count -gt 0) { $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " @@ -275,9 +345,12 @@ try { Where-Object { $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) $_.Name -ne $preserveFileName -and - -not $isInSkippedDirectory + -not $preservedRelativePaths.Contains($relativePath) -and + (-not $isInSkippedDirectory -or $isInOmittedDirectory) } foreach ($file in $filesToRemove) { @@ -289,7 +362,10 @@ try { foreach ($directory in $directoriesToRemove) { $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName) - if (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) { + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) + if ((Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) -and + -not $isInOmittedDirectory) { continue } @@ -305,8 +381,10 @@ try { Where-Object { $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) - -not $isInSkippedDirectory + -not $isInSkippedDirectory -and -not $isInOmittedDirectory } foreach ($sourceFile in $sourceFilesToCopy) { @@ -326,6 +404,9 @@ try { Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" } } + foreach ($omittedDirectory in $omittedRelativeDirectories) { + Write-Log -Level "INFO" -Message "Omitted (not shipped): $omittedDirectory" + } Write-Log -Level "OK" -Message "Source files copied" if ($preservedFiles.Count -gt 0) { @@ -342,7 +423,7 @@ try { Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force } - Write-Log -Level "OK" -Message "$preserveFileName files restored" + Write-Log -Level "OK" -Message "Preserved files restored ($($preservedFiles.Count))" } Write-Log -Level "OK" -Message "========================================" diff --git a/utils/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json index de67aab..d79ebc5 100644 --- a/utils/tools/Update-RepoUtils/scriptSettings.json +++ b/utils/tools/Update-RepoUtils/scriptSettings.json @@ -11,6 +11,9 @@ "skippedRelativeDirectories": [ "engines/release/custom", "engines/test/custom" + ], + "omittedRelativeDirectories": [ + "tests" ] } }