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.
-  
+
+
+
## 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 @@
-
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 @@
-
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 @@
-
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 ""
+}
+
+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"
]
}
}