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 5f09bf8..402b144 100644
--- a/.gitignore
+++ b/.gitignore
@@ -263,4 +263,10 @@ __pycache__/
**/*docker-compose/LetsEncryptServer/acme
-**/*docker-compose/LetsEncryptServer/cache
\ No newline at end of file
+**/*docker-compose/LetsEncryptServer/cache
+#Custom
+![Uu]tils/**
+
+# Generated test reports (RepoUtils)
+test-results/
+utils/tests/results/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 902ed5f..9bc3fe6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,13 @@ 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).
-## [Unreleased]
+## [2.0.1] - 2026-06-28
### Changed
-- Migrated repository automation from `utils/` to `src/` (maksit-repoutils layout). Configure release and test flows in `src/engines/*/scriptSettings.json`.
+- Restored repository automation under `utils/` (aligned with maksit-core and maksit-repoutils). Configure release and test flows in `utils/engines/*/scriptSettings.json`.
+- Updated dependencies to Dapr `1.18.4`, `MaksIT.Core` `1.6.8`, and `MaksIT.Results` `2.0.3`.
-## v2.0.0 - 2026-02-22
+## [2.0.0] - 2026-02-22
### Added
- Dedicated test project (`MaksIT.Dapr.Tests`) with coverage for publisher and state-store service behavior.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f29fb0a..5fed35d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -72,48 +72,53 @@ This project follows [Semantic Versioning](https://semver.org/):
## Utility Scripts
-Automation lives under `src/` (synced from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils) via `Update-RepoUtils`). Product-specific settings are in each engine’s `scriptSettings.json`.
+Automation lives under `utils/` (synced from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils) via `Update-RepoUtils`). Product-specific settings are in each engine’s `scriptSettings.json`.
### Run tests and coverage badges
```powershell
-pwsh -File .\src\engines\test\Invoke-TestEngine.ps1
+pwsh -File .\utils\engines\test\Invoke-TestEngine.ps1
```
-Configuration: `src/engines/test/scriptSettings.json`
+Or double-click `utils\Invoke-TestEngine.bat`.
+
+Configuration: `utils/engines/test/scriptSettings.json`
### Release package
Builds, tests, packs, and publishes to NuGet and GitHub release flows.
```powershell
-pwsh -File .\src\engines\release\Invoke-ReleasePackage.ps1
+pwsh -File .\utils\engines\release\Invoke-ReleasePackage.ps1
```
+Or run `utils\Invoke-ReleasePackage-Single.bat` (or `-HA.bat` when Helm deploy is wired).
+
Prerequisites:
- Docker Desktop (for Linux test validation)
- GitHub CLI (`gh`)
-- environment variable `NUGET_MAKS_IT`
-- environment variable `GITHUB_MAKS_IT_COM`
+- Environment variables **`NuGet`** and **`GitHub`** (logical names in `scriptSettings.json`)
-Configuration: `src/engines/release/scriptSettings.json`
+Configuration: `utils/engines/release/scriptSettings.json`
### Update repo utilities
-Refreshes `src/` from maksit-repoutils while preserving local `scriptSettings.json` files.
+Refreshes `utils/` from maksit-repoutils while preserving local `scriptSettings.json` files.
```powershell
-pwsh -File .\src\tools\Update-RepoUtils\Update-RepoUtils.ps1
+pwsh -File .\utils\tools\Update-RepoUtils\Update-RepoUtils.ps1
```
+Or double-click `utils\Update-RepoUtils.bat`.
+
### Force amend tagged commit
Amends the latest tagged commit and force-pushes updated branch and tag.
```powershell
-pwsh -File .\src\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1
-pwsh -File .\src\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun
+pwsh -File .\utils\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1
+pwsh -File .\utils\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun
```
Warning: this rewrites git history.
diff --git a/README.md b/README.md
index 973edc9..82a608f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# MaksIT.Dapr
-  
+
+
+
This repository hosts the `maksit-dapr` project, which utilizes [Dapr](https://dapr.io/) (Distributed Application Runtime) to facilitate building and managing microservices with ease. The project focuses on implementing a robust, scalable solution leveraging Dapr's building blocks and abstractions.
diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg
deleted file mode 100644
index ee69f16..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 64fd339..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 2ae5b4f..0000000
--- a/assets/badges/coverage-methods.svg
+++ /dev/null
@@ -1,21 +0,0 @@
-
diff --git a/src/Invoke-ReleasePackage.bat b/src/Invoke-ReleasePackage.bat
deleted file mode 100644
index 85d776a..0000000
--- a/src/Invoke-ReleasePackage.bat
+++ /dev/null
@@ -1,3 +0,0 @@
-@echo off
-pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
-pause
diff --git a/src/Invoke-TestEngine.bat b/src/Invoke-TestEngine.bat
deleted file mode 100644
index 0cfd13f..0000000
--- a/src/Invoke-TestEngine.bat
+++ /dev/null
@@ -1,3 +0,0 @@
-@echo off
-pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
-pause
diff --git a/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj
index 5623534..3921c40 100644
--- a/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj
+++ b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj
@@ -12,7 +12,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/src/MaksIT.Dapr/MaksIT.Dapr.csproj b/src/MaksIT.Dapr/MaksIT.Dapr.csproj
index c53c63e..b77270e 100644
--- a/src/MaksIT.Dapr/MaksIT.Dapr.csproj
+++ b/src/MaksIT.Dapr/MaksIT.Dapr.csproj
@@ -7,7 +7,7 @@
MaksIT.Dapr
- 2.0.0
+ 2.0.1
Maksym Sadovnychyy
MAKS-IT
MaksIT.Dapr
@@ -21,11 +21,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/src/modules/Engine/EngineContext.psm1 b/src/modules/Engine/EngineContext.psm1
deleted file mode 100644
index 9d397d1..0000000
--- a/src/modules/Engine/EngineContext.psm1
+++ /dev/null
@@ -1,225 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- Helpers to resolve engine semver and relative paths from plugin configuration.
-
-.DESCRIPTION
- Used by New-EngineContext and version plugins:
- - DotNetReleaseVersion plugin -> projectFiles (.csproj )
- - NpmReleaseVersion plugin -> packageJsonPath (package.json version)
-#>
-
-if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
- $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
- if (Test-Path $loggingModulePath -PathType Leaf) {
- Import-Module $loggingModulePath -Force
- }
-}
-
-function Resolve-RelativePaths {
- param(
- [Parameter(Mandatory = $true)]
- [object]$Value,
-
- [Parameter(Mandatory = $true)]
- [string]$BasePath
- )
-
- if ($null -eq $Value) {
- return @()
- }
-
- $rawPaths = @()
- if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
- $rawPaths += $Value
- }
- else {
- $rawPaths += $Value
- }
-
- $resolved = @()
- foreach ($p in $rawPaths) {
- if ([string]::IsNullOrWhiteSpace([string]$p)) {
- continue
- }
-
- $resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p)))
- }
-
- return @($resolved)
-}
-
-function Get-CsprojPropertyValue {
- param(
- [Parameter(Mandatory = $true)]
- [xml]$Csproj,
-
- [Parameter(Mandatory = $true)]
- [string]$PropertyName
- )
-
- # SDK-style .csproj files can have multiple PropertyGroup nodes.
- # Use the first group that defines the requested property.
- $propNode = $Csproj.Project.PropertyGroup |
- Where-Object { $_.$PropertyName } |
- Select-Object -First 1
-
- if ($propNode) {
- return $propNode.$PropertyName
- }
-
- return $null
-}
-
-function Get-CsprojVersions {
- param(
- [Parameter(Mandatory = $true)]
- [string[]]$ProjectFiles
- )
-
- Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..."
- $projectVersions = @{}
-
- foreach ($projectPath in $ProjectFiles) {
- if (-not (Test-Path $projectPath -PathType Leaf)) {
- Write-Error "Project file not found at: $projectPath"
- exit 1
- }
-
- if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") {
- Write-Error "Configured project file is not a .csproj file: $projectPath"
- exit 1
- }
-
- [xml]$csproj = Get-Content $projectPath
- $version = Get-CsprojPropertyValue -Csproj $csproj -PropertyName "Version"
-
- if (-not $version) {
- Write-Error "Version not found in $projectPath"
- exit 1
- }
-
- $projectVersions[$projectPath] = $version
- Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version"
- }
-
- return $projectVersions
-}
-
-function Resolve-DotNetReleaseVersion {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$ScriptDir
- )
-
- $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1)
- if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
- Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles."
- exit 1
- }
-
- $releaseVersionSettings = $releaseVersionPlugin[0]
- $projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir)
-
- if ($projectFiles.Count -eq 0) {
- Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with )."
- exit 1
- }
-
- $projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles
- $version = $projectVersions[$projectFiles[0]]
-
- return [pscustomobject]@{
- version = $version
- source = 'DotNetReleaseVersion'
- }
-}
-
-function Resolve-NpmReleaseVersion {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$ScriptDir
- )
-
- $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1)
- if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
- Write-Error "Configure an NpmReleaseVersion plugin in scriptSettings.json with packageJsonPath."
- exit 1
- }
-
- $releaseVersionSettings = $releaseVersionPlugin[0]
- $packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir)
-
- if ($packageJsonPaths.Count -eq 0) {
- Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath."
- exit 1
- }
-
- $packageJsonPath = $packageJsonPaths[0]
- if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
- Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath"
- exit 1
- }
-
- Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..."
- $json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
- $version = [string]$json.version
- if ([string]::IsNullOrWhiteSpace($version)) {
- Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'."
- exit 1
- }
-
- if ($version -notmatch '^\d+\.\d+\.\d+') {
- Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver."
- exit 1
- }
-
- Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version"
-
- return [pscustomobject]@{
- version = $version
- source = 'NpmReleaseVersion'
- }
-}
-
-function Resolve-ReleaseVersion {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$ScriptDir
- )
-
- $dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false })
- $npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false })
-
- if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) {
- Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both."
- exit 1
- }
-
- if ($dotnetPlugin.Count -gt 0) {
- return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
- }
-
- if ($npmPlugin.Count -gt 0) {
- return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
- }
-
- Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json."
- exit 1
-}
-
-Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion
-
-
-
diff --git a/src/modules/Engine/PluginSupport.psm1 b/src/modules/Engine/PluginSupport.psm1
deleted file mode 100644
index 30371ea..0000000
--- a/src/modules/Engine/PluginSupport.psm1
+++ /dev/null
@@ -1,386 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-function Get-RepoUtilsSrcDirectory {
- return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
-}
-
-function Get-RepoUtilsModulesDirectory {
- return Split-Path $PSScriptRoot -Parent
-}
-
-if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
- $loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1"
- if (Test-Path $loggingModulePath -PathType Leaf) {
- Import-Module $loggingModulePath -Force
- }
-}
-
-function Import-PluginDependency {
- param(
- [Parameter(Mandatory = $true)]
- [string]$ModuleName,
-
- [Parameter(Mandatory = $true)]
- [string]$RequiredCommand
- )
-
- if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) {
- return
- }
-
- $modulesDir = Get-RepoUtilsModulesDirectory
- $engineModuleDir = $PSScriptRoot
- $modulePath = Join-Path $modulesDir "$ModuleName.psm1"
- if (-not (Test-Path $modulePath -PathType Leaf)) {
- $modulePath = Join-Path $engineModuleDir "$ModuleName.psm1"
- }
-
- if (Test-Path $modulePath -PathType Leaf) {
- Import-Module $modulePath -Force -Global -ErrorAction Stop
- }
-
- if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) {
- throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'."
- }
-}
-
-function Get-ConfiguredPlugins {
- param(
- [Parameter(Mandatory = $true)]
- [psobject]$Settings
- )
-
- if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) {
- return @()
- }
-
- if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
- return @($Settings.plugins)
- }
-
- return @($Settings.plugins)
-}
-
-function Get-PluginStageLabel {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin
- )
-
- if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
- return 'release'
- }
-
- return [string]$Plugin.stageLabel
-}
-
-function Get-PluginBranches {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin
- )
-
- if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) {
- return @()
- }
-
- if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) {
- return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
- }
-
- if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) {
- return @()
- }
-
- return @([string]$Plugin.branches)
-}
-
-function Test-PluginAllowedOnBranch {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin,
-
- [Parameter(Mandatory = $true)]
- [string]$CurrentBranch
- )
-
- $allowedBranches = Get-PluginBranches -Plugin $Plugin
- if ($allowedBranches.Count -eq 0) {
- return $true
- }
-
- if ($allowedBranches -contains '*') {
- return $true
- }
-
- return $allowedBranches -contains $CurrentBranch
-}
-
-function Test-IsPublishPlugin {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin
- )
-
- if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
- return $false
- }
-
- return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name)
-}
-
-function Get-PluginSettingValue {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$PropertyName
- )
-
- foreach ($plugin in $Plugins) {
- if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
- continue
- }
-
- if (-not $plugin.PSObject.Properties[$PropertyName]) {
- continue
- }
-
- $value = $plugin.$PropertyName
- if ($null -eq $value) {
- continue
- }
-
- if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
- continue
- }
-
- return $value
- }
-
- return $null
-}
-
-function Get-PluginPathListSetting {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$PropertyName,
-
- [Parameter(Mandatory = $true)]
- [string]$BasePath
- )
-
- $rawPaths = @()
- $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
-
- if ($null -eq $value) {
- return @()
- }
-
- if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
- $rawPaths += $value
- }
- else {
- $rawPaths += $value
- }
-
- $resolvedPaths = @()
- foreach ($path in $rawPaths) {
- if ([string]::IsNullOrWhiteSpace([string]$path)) {
- continue
- }
-
- $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path)))
- }
-
- return @($resolvedPaths)
-}
-
-function Get-PluginPathSetting {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$PropertyName,
-
- [Parameter(Mandatory = $true)]
- [string]$BasePath
- )
-
- $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
- if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
- return $null
- }
-
- return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value)))
-}
-
-function Get-ArchiveNamePattern {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$CurrentBranch
- )
-
- foreach ($plugin in $Plugins) {
- if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
- continue
- }
-
- if (-not $plugin.enabled) {
- continue
- }
-
- if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
- continue
- }
-
- if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) {
- return [string]$plugin.zipNamePattern
- }
- }
-
- return "release-{version}.zip"
-}
-
-function Resolve-PluginModulePath {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin,
-
- [Parameter(Mandatory = $true)]
- [string]$EngineDirectory
- )
-
- $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)
- )
-
- foreach ($candidatePath in $candidatePaths) {
- if (Test-Path $candidatePath -PathType Leaf) {
- return $candidatePath
- }
- }
-
- return $candidatePaths[0]
-}
-
-function Test-PluginRunnable {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin,
-
- [Parameter(Mandatory = $true)]
- [psobject]$SharedSettings,
-
- [Parameter(Mandatory = $true)]
- [string]$EngineDirectory,
-
- [Parameter(Mandatory = $false)]
- [bool]$WriteLogs = $true
- )
-
- if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
- if ($WriteLogs) {
- Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
- }
- return $false
- }
-
- if (-not $Plugin.enabled) {
- if ($WriteLogs) {
- Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)."
- }
- return $false
- }
-
- $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
- if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
- if ($WriteLogs) {
- Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
- }
- return $false
- }
-
- return $true
-}
-
-function New-PluginInvocationSettings {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin,
-
- [Parameter(Mandatory = $true)]
- [psobject]$SharedSettings
- )
-
- $properties = @{}
- foreach ($property in $Plugin.PSObject.Properties) {
- $properties[$property.Name] = $property.Value
- }
-
- $properties['context'] = $SharedSettings
- return [pscustomobject]$properties
-}
-
-function Invoke-ConfiguredPlugin {
- param(
- [Parameter(Mandatory = $true)]
- $Plugin,
-
- [Parameter(Mandatory = $true)]
- [psobject]$SharedSettings,
-
- [Parameter(Mandatory = $true)]
- [string]$EngineDirectory,
-
- [Parameter(Mandatory = $false)]
- [bool]$ContinueOnError = $false
- )
-
- if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
- if ($Plugin.enabled) {
- return $false
- }
-
- return $true
- }
-
- if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) {
- Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)."
- return $true
- }
-
- $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
- Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
-
- try {
- $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
- $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
- $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
-
- & $invokeCommand -Settings $pluginSettings
- Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
- return $true
- }
- catch {
- Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
- return $false
- }
-}
-
-Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
diff --git a/src/modules/Engine/ReleaseSupport.psm1 b/src/modules/Engine/ReleaseSupport.psm1
deleted file mode 100644
index ffda8d7..0000000
--- a/src/modules/Engine/ReleaseSupport.psm1
+++ /dev/null
@@ -1,151 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-$modulesDir = Split-Path $PSScriptRoot -Parent
-
-if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
- $loggingModulePath = Join-Path $modulesDir "Logging.psm1"
- if (Test-Path $loggingModulePath -PathType Leaf) {
- Import-Module $loggingModulePath -Force
- }
-}
-
-if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
- $gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1"
- if (Test-Path $gitToolsModulePath -PathType Leaf) {
- Import-Module $gitToolsModulePath -Force
- }
-}
-
-if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) {
- $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
- if (Test-Path $pluginSupportModulePath -PathType Leaf) {
- Import-Module $pluginSupportModulePath -Force
- }
-}
-
-if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) {
- $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1"
- if (Test-Path $engineContextModulePath -PathType Leaf) {
- Import-Module $engineContextModulePath -Force
- }
-}
-
-function Assert-WorkingTreeClean {
- $gitStatus = Get-GitStatusShort
- if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) {
- Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)."
- foreach ($line in @([string]$gitStatus -split "`r?`n")) {
- if (-not [string]::IsNullOrWhiteSpace($line)) {
- Write-Log -Level "WARN" -Message " $line"
- }
- }
- return
- }
-
- Write-Log -Level "OK" -Message " Working directory is clean."
-}
-
-function Initialize-ReleaseStageContext {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$RemainingPlugins,
-
- [Parameter(Mandatory = $true)]
- [psobject]$SharedSettings,
-
- [Parameter(Mandatory = $true)]
- [string]$ArtifactsDirectory,
-
- [Parameter(Mandatory = $true)]
- [string]$Version
- )
-
- if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) {
- $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force
- }
-}
-
-function New-EngineContext {
- param(
- [Parameter(Mandatory = $true)]
- [object[]]$Plugins,
-
- [Parameter(Mandatory = $true)]
- [string]$ScriptDir,
-
- [Parameter(Mandatory = $true)]
- [string]$SrcDir,
-
- [Parameter(Mandatory = $false)]
- [psobject]$Settings
- )
-
- $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
- $version = $resolvedVersion.version
- $versionSource = $resolvedVersion.source
- $releaseRelative = '..\..\..\release'
- $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative))
-
- $currentBranch = Get-CurrentBranch
-
- $releaseBranches = @()
- foreach ($p in $Plugins) {
- if (-not $p.enabled) { continue }
- if ([string]$p.name -ne 'ReleasePublishGuard') { continue }
- foreach ($b in (Get-PluginBranches -Plugin $p)) {
- $releaseBranches += $b
- }
- }
- $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
- if ($releaseBranches.Count -eq 0) {
- foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) {
- if (-not $p.enabled) { continue }
- foreach ($b in (Get-PluginBranches -Plugin $p)) {
- $releaseBranches += $b
- }
- }
- $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
- }
- if ($releaseBranches.Count -eq 0) {
- $releaseBranches = @('main')
- }
-
- $isReleaseBranch = $releaseBranches -contains $currentBranch
- $isNonReleaseBranch = -not $isReleaseBranch
-
- Assert-WorkingTreeClean
-
- $tag = "v$version"
- Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
-
- return [pscustomobject]@{
- scriptDir = $ScriptDir
- srcDir = $SrcDir
- utilsDir = $SrcDir
- currentBranch = $currentBranch
- version = $version
- tag = $tag
- artifactsDirectory = $artifactsDirectory
- isReleaseBranch = $isReleaseBranch
- isNonReleaseBranch = $isNonReleaseBranch
- releaseBranches = $releaseBranches
- publishCompleted = $false
- skipPublishPlugins = $false
- }
-}
-
-function Get-PreferredReleaseBranch {
- param(
- [Parameter(Mandatory = $true)]
- [psobject]$EngineContext
- )
-
- if ($EngineContext.releaseBranches.Count -gt 0) {
- return $EngineContext.releaseBranches[0]
- }
-
- return "main"
-}
-
-Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch
diff --git a/src/modules/Engine/TestSupport.psm1 b/src/modules/Engine/TestSupport.psm1
deleted file mode 100644
index a90d03d..0000000
--- a/src/modules/Engine/TestSupport.psm1
+++ /dev/null
@@ -1,38 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-$modulesDir = Split-Path $PSScriptRoot -Parent
-
-if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
- $loggingModulePath = Join-Path $modulesDir "Logging.psm1"
- if (Test-Path $loggingModulePath -PathType Leaf) {
- Import-Module $loggingModulePath -Force
- }
-}
-
-function New-EngineContext {
- param(
- [Parameter(Mandatory = $true)]
- [string]$ScriptDir,
-
- [Parameter(Mandatory = $true)]
- [string]$SrcDir,
-
- [Parameter(Mandatory = $false)]
- [psobject]$Settings
- )
-
- $badgesDir = $null
- if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) {
- $badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir)))
- }
-
- return [pscustomobject]@{
- scriptDir = $ScriptDir
- srcDir = $SrcDir
- utilsDir = $SrcDir
- badgesDir = $badgesDir
- }
-}
-
-Export-ModuleMember -Function New-EngineContext
diff --git a/src/plugins/DotNet/DotNetDockerPush.psm1 b/src/plugins/DotNet/DotNetDockerPush.psm1
deleted file mode 100644
index 6e6855a..0000000
--- a/src/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/src/plugins/DotNet/DotNetHelmPush.psm1 b/src/plugins/DotNet/DotNetHelmPush.psm1
deleted file mode 100644
index 44532f0..0000000
--- a/src/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/src/plugins/DotNet/DotNetReleaseVersion.psm1 b/src/plugins/DotNet/DotNetReleaseVersion.psm1
deleted file mode 100644
index 66ac1b6..0000000
--- a/src/plugins/DotNet/DotNetReleaseVersion.psm1
+++ /dev/null
@@ -1,41 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- Loads release version into shared context.
-
-.DESCRIPTION
- Dedicated version-loading plugin. It reads .csproj version via
- EngineContext helpers and writes Version into the shared runtime context.
-#>
-
-if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
- $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
- $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
- if (Test-Path $pluginSupportModulePath -PathType Leaf) {
- Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
- }
-}
-
-function Invoke-Plugin {
- param(
- [Parameter(Mandatory = $true)]
- $Settings
- )
-
- Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
- Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-DotNetReleaseVersion"
-
- $shared = $Settings.context
- $resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir
- $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir)
-
- $shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force
- $shared | Add-Member -NotePropertyName projectFiles -NotePropertyValue $projectFiles -Force
- Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $($shared.version)"
-}
-
-Export-ModuleMember -Function Invoke-Plugin
-
-
diff --git a/src/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit.bat
similarity index 100%
rename from src/Force-AmendTaggedCommit.bat
rename to utils/Force-AmendTaggedCommit.bat
diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat
new file mode 100644
index 0000000..2255f3c
--- /dev/null
+++ b/utils/Invoke-ReleasePackage.bat
@@ -0,0 +1,4 @@
+@echo off
+setlocal
+pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
+exit /b %ERRORLEVEL%
diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat
new file mode 100644
index 0000000..7953777
--- /dev/null
+++ b/utils/Invoke-TestEngine.bat
@@ -0,0 +1,4 @@
+@echo off
+setlocal
+pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
+exit /b %ERRORLEVEL%
diff --git a/src/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat
similarity index 100%
rename from src/Update-RepoUtils.bat
rename to utils/Update-RepoUtils.bat
diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1
new file mode 100644
index 0000000..4ad9cbc
--- /dev/null
+++ b/utils/engines/release/Invoke-ReleasePackage.ps1
@@ -0,0 +1,118 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.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.
+#>
+
+# 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
+
+$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 $releaseBanner
+Write-Log -Level 'STEP' -Message '=================================================='
+
+$plugins = $configuredPlugins
+$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
+
+$releaseStageInitialized = $false
+$releaseHadPluginFailures = $false
+
+if ($plugins.Count -eq 0) {
+ Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.'
+}
+else {
+ for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
+ $plugin = $plugins[$pluginIndex]
+
+ if ((Test-IsPublishPlugin -Plugin $plugin -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 $PSScriptRoot -ContinueOnError:$false
+ if (-not $pluginSucceeded) {
+ $releaseHadPluginFailures = $true
+ break
+ }
+ }
+}
+
+if (-not $releaseStageInitialized) {
+ $noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' }
+ Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).'
+}
+
+Write-Log -Level 'OK' -Message '=================================================='
+if ($releaseHadPluginFailures) {
+ Write-Log -Level 'ERROR' -Message 'RELEASE FAILED'
+}
+elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) {
+ Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)'
+}
+elseif ($engineContext.isNonReleaseBranch) {
+ Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE'
+}
+else {
+ Write-Log -Level 'OK' -Message 'RELEASE COMPLETE'
+}
+Write-Log -Level 'OK' -Message '=================================================='
+
+if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
+ $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
+ Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements."
+}
+
+if ($releaseHadPluginFailures) {
+ exit 1
+}
diff --git a/utils/engines/release/custom/.gitkeep b/utils/engines/release/custom/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/utils/engines/release/custom/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json
new file mode 100644
index 0000000..4b17cf3
--- /dev/null
+++ b/utils/engines/release/scriptSettings.json
@@ -0,0 +1,114 @@
+{
+ "$schema": "https://json-schema.org/draft-07/schema",
+ "title": "Release Package Script Settings",
+ "description": "Invoke-ReleasePackage.ps1 plugin settings for maksit-dapr.",
+ "plugins": [
+ {
+ "name": "DotNetReleaseVersion",
+ "stageLabel": "build",
+ "enabled": true,
+ "projectFiles": [
+ "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj"
+ ]
+ },
+ {
+ "name": "DotNetTest",
+ "stageLabel": "test",
+ "enabled": true,
+ "project": "..\\..\\..\\src\\MaksIT.Dapr.Tests",
+ "resultsDir": "..\\..\\..\\testResults"
+ },
+ {
+ "name": "QualityGate",
+ "stageLabel": "qualityGate",
+ "enabled": true,
+ "coverageThreshold": 0,
+ "failOnVulnerabilities": true,
+ "projectFiles": [
+ "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj"
+ ]
+ },
+ {
+ "name": "DotNetPack",
+ "stageLabel": "build",
+ "enabled": true,
+ "projectFiles": [
+ "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj"
+ ],
+ "artifactsDir": "..\\..\\..\\releases"
+ },
+ {
+ "name": "DotNetCreateArchive",
+ "stageLabel": "build",
+ "enabled": true,
+ "zipNamePattern": "maksit.dapr-{version}.zip"
+ },
+ {
+ "name": "ReleasePublishGuard",
+ "stageLabel": "release",
+ "enabled": true,
+ "branches": [
+ "main"
+ ],
+ "requireExactTagOnHead": true,
+ "tagVersionMustMatchDotNetRelease": true,
+ "whenRequirementsNotMet": "skip",
+ "requireCleanWorkingTree": false,
+ "ensureTagOnRemote": true,
+ "remoteName": "origin"
+ },
+ {
+ "name": "GitHub",
+ "stageLabel": "release",
+ "enabled": true,
+ "githubSecret": "GitHub",
+ "repository": "https://github.com/MAKS-IT-COM/maksit-core-dapr",
+ "releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
+ "releaseTitlePattern": "Release {version}"
+ },
+ {
+ "name": "DotNetNuGet",
+ "stageLabel": "release",
+ "enabled": true,
+ "nugetSecret": "NuGet",
+ "source": "https://api.nuget.org/v3/index.json"
+ },
+ {
+ "name": "NpmReleaseVersion",
+ "stageLabel": "build",
+ "enabled": false,
+ "packageJsonPath": "..\\..\\..\\src\\package.json",
+ "syncWorkspaceVersions": true
+ },
+ {
+ "name": "NpmBuild",
+ "stageLabel": "build",
+ "enabled": false,
+ "workspaceRoot": "..\\..\\..\\src",
+ "useCi": true,
+ "buildScript": "build"
+ },
+ {
+ "name": "NpmPublish",
+ "stageLabel": "release",
+ "enabled": false,
+ "npmSecret": "Npm",
+ "registry": "https://registry.npmjs.org",
+ "access": "public",
+ "workspaceRoot": "..\\..\\..\\src",
+ "publishOrder": []
+ },
+ {
+ "name": "DotNetCleanupArtifacts",
+ "stageLabel": "release",
+ "enabled": true,
+ "includePatterns": [
+ "*"
+ ],
+ "excludePatterns": [
+ "*.zip"
+ ]
+ }
+ ]
+}
+
diff --git a/src/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1
similarity index 100%
rename from src/engines/test/Invoke-TestEngine.ps1
rename to utils/engines/test/Invoke-TestEngine.ps1
diff --git a/src/engines/test/custom/.gitkeep b/utils/engines/test/custom/.gitkeep
similarity index 100%
rename from src/engines/test/custom/.gitkeep
rename to utils/engines/test/custom/.gitkeep
diff --git a/src/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json
similarity index 78%
rename from src/engines/test/scriptSettings.json
rename to utils/engines/test/scriptSettings.json
index 44d5676..3d0c51f 100644
--- a/src/engines/test/scriptSettings.json
+++ b/utils/engines/test/scriptSettings.json
@@ -2,17 +2,15 @@
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings",
"description": "Test engine and coverage badges for maksit-dapr.",
- "paths": {
- "badgesDir": "..\\..\\..\\assets\\badges"
- },
"plugins": [
{
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"projects": [
- "..\\..\\MaksIT.Dapr.Tests"
- ]
+ "..\\..\\..\\src\\MaksIT.Dapr.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"
}
@@ -52,5 +48,9 @@
"red": 0
}
}
- ]
+ ],
+ "paths": {
+ "readmePath": "..\\..\\..\\README.md",
+ "testResultsDir": "..\\..\\..\\test-results"
+ }
}
diff --git a/src/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1
similarity index 100%
rename from src/modules/ChangelogSupport.psm1
rename to utils/modules/ChangelogSupport.psm1
diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1
new file mode 100644
index 0000000..3b94fa9
--- /dev/null
+++ b/utils/modules/Engine/EngineContext.psm1
@@ -0,0 +1,332 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.SYNOPSIS
+ Generic engine helpers: path resolution and the shared context facts API.
+
+.DESCRIPTION
+ 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)) {
+ $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
+ if (Test-Path $loggingModulePath -PathType Leaf) {
+ Import-Module $loggingModulePath -Force
+ }
+}
+
+$script:EngineStateAllowlist = @(
+ 'version'
+ 'tag'
+ 'skipPublishPlugins'
+ 'releaseDir'
+ 'artifactsDirectory'
+ 'deployMode'
+ 'orchestrator'
+)
+
+function Resolve-RelativePaths {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object]$Value,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BasePath
+ )
+
+ if ($null -eq $Value) {
+ return @()
+ }
+
+ $rawPaths = @()
+ if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
+ $rawPaths += $Value
+ }
+ else {
+ $rawPaths += $Value
+ }
+
+ $resolved = @()
+ foreach ($p in $rawPaths) {
+ if ([string]::IsNullOrWhiteSpace([string]$p)) {
+ continue
+ }
+
+ $resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p)))
+ }
+
+ return @($resolved)
+}
+
+function Initialize-EngineFactsBag {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$Context
+ )
+
+ if (-not ($Context.PSObject.Properties.Name -contains 'facts') -or $null -eq $Context.facts) {
+ $Context | Add-Member -NotePropertyName facts -NotePropertyValue ([ordered]@{}) -Force
+ }
+}
+
+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 Test-EngineFact {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$Context,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Namespace,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Name,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$LegacyProperty
+ )
+
+ $value = Get-EngineFact -Context $Context -Namespace $Namespace -Name $Name -LegacyProperty $LegacyProperty
+ return (Test-EngineFactValuePresent -Value $value)
+}
+
+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 ($Required) {
+ throw "Required engine state '$Name' is missing."
+ }
+
+ if ($PSBoundParameters.ContainsKey('Default')) {
+ return $Default
+ }
+
+ return $null
+}
+
+Export-ModuleMember -Function `
+ Resolve-RelativePaths, `
+ Initialize-EngineFactsBag, `
+ Set-EngineFact, `
+ Get-EngineFact, `
+ Test-EngineFact, `
+ Set-EngineState, `
+ Add-EnginePublishCompletion, `
+ Get-EngineState
diff --git a/src/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1
similarity index 84%
rename from src/modules/Engine/Import-EngineModules.ps1
rename to utils/modules/Engine/Import-EngineModules.ps1
index 5c43ed7..a557a58 100644
--- a/src/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
new file mode 100644
index 0000000..8d8c482
--- /dev/null
+++ b/utils/modules/Engine/PluginSupport.psm1
@@ -0,0 +1,734 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+function Get-RepoUtilsSrcDirectory {
+ return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
+}
+
+function Get-RepoUtilsModulesDirectory {
+ return Split-Path $PSScriptRoot -Parent
+}
+
+if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
+ $loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1"
+ if (Test-Path $loggingModulePath -PathType Leaf) {
+ Import-Module $loggingModulePath -Force
+ }
+}
+
+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)]
+ [string]$ModuleName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$RequiredCommand
+ )
+
+ if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) {
+ return
+ }
+
+ $modulesDir = Get-RepoUtilsModulesDirectory
+ $engineModuleDir = $PSScriptRoot
+ $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"))
+ }
+ }
+
+ 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)) {
+ throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'."
+ }
+}
+
+function Get-ConfiguredPlugins {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$Settings
+ )
+
+ if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) {
+ return @()
+ }
+
+ if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
+ return @($Settings.plugins)
+ }
+
+ return @($Settings.plugins)
+}
+
+function Get-PluginStageLabel {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin
+ )
+
+ if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
+ return 'release'
+ }
+
+ return [string]$Plugin.stageLabel
+}
+
+function Get-PluginBranches {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin
+ )
+
+ if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) {
+ return @()
+ }
+
+ if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) {
+ return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) {
+ return @()
+ }
+
+ return @([string]$Plugin.branches)
+}
+
+function Test-PluginAllowedOnBranch {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin,
+
+ [Parameter(Mandatory = $true)]
+ [string]$CurrentBranch
+ )
+
+ $allowedBranches = Get-PluginBranches -Plugin $Plugin
+ if ($allowedBranches.Count -eq 0) {
+ return $true
+ }
+
+ if ($allowedBranches -contains '*') {
+ return $true
+ }
+
+ return $allowedBranches -contains $CurrentBranch
+}
+
+function Get-PluginMetadataObject {
+ param(
+ [Parameter(Mandatory = $true)]
+ $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
+ }
+
+ 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 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PropertyName
+ )
+
+ foreach ($plugin in $Plugins) {
+ if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
+ continue
+ }
+
+ if (-not $plugin.PSObject.Properties[$PropertyName]) {
+ continue
+ }
+
+ $value = $plugin.$PropertyName
+ if ($null -eq $value) {
+ continue
+ }
+
+ if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
+ continue
+ }
+
+ return $value
+ }
+
+ return $null
+}
+
+function Get-PluginPathListSetting {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PropertyName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BasePath
+ )
+
+ $rawPaths = @()
+ $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
+
+ if ($null -eq $value) {
+ return @()
+ }
+
+ if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
+ $rawPaths += $value
+ }
+ else {
+ $rawPaths += $value
+ }
+
+ $resolvedPaths = @()
+ foreach ($path in $rawPaths) {
+ if ([string]::IsNullOrWhiteSpace([string]$path)) {
+ continue
+ }
+
+ $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path)))
+ }
+
+ return @($resolvedPaths)
+}
+
+function Get-PluginPathSetting {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PropertyName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BasePath
+ )
+
+ $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
+ if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
+ return $null
+ }
+
+ return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value)))
+}
+
+function Get-ArchiveNamePattern {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$CurrentBranch
+ )
+
+ foreach ($plugin in $Plugins) {
+ if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
+ continue
+ }
+
+ if (-not $plugin.enabled) {
+ continue
+ }
+
+ if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
+ continue
+ }
+
+ if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) {
+ return [string]$plugin.zipNamePattern
+ }
+ }
+
+ return "release-{version}.zip"
+}
+
+function Resolve-PluginModulePath {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EngineDirectory
+ )
+
+ $srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent
+ $pluginsRoot = Join-Path $srcDir "plugins"
+ $pluginFileName = "{0}.psm1" -f $Plugin.name
+ $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) {
+ return $candidatePath
+ }
+ }
+
+ return $candidatePaths[0]
+}
+
+function Test-PluginRunnable {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin,
+
+ [Parameter(Mandatory = $true)]
+ [psobject]$SharedSettings,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EngineDirectory,
+
+ [Parameter(Mandatory = $false)]
+ [bool]$WriteLogs = $true
+ )
+
+ if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
+ if ($WriteLogs) {
+ Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
+ }
+ return $false
+ }
+
+ if (-not $Plugin.enabled) {
+ if ($WriteLogs) {
+ Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)."
+ }
+ return $false
+ }
+
+ $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
+ if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
+ if ($WriteLogs) {
+ Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
+ }
+ return $false
+ }
+
+ return $true
+}
+
+function New-PluginInvocationSettings {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin,
+
+ [Parameter(Mandatory = $true)]
+ [psobject]$SharedSettings
+ )
+
+ $properties = @{}
+ foreach ($property in $Plugin.PSObject.Properties) {
+ $properties[$property.Name] = $property.Value
+ }
+
+ $properties['context'] = $SharedSettings
+ return [pscustomobject]$properties
+}
+
+function Invoke-ConfiguredPlugin {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Plugin,
+
+ [Parameter(Mandatory = $true)]
+ [psobject]$SharedSettings,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EngineDirectory,
+
+ [Parameter(Mandatory = $false)]
+ [bool]$ContinueOnError = $false
+ )
+
+ if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
+ if ($Plugin.enabled) {
+ return $false
+ }
+
+ 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)'..."
+
+ try {
+ $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
+ $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
+ $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
+
+ & $invokeCommand -Settings $pluginSettings
+ Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
+ return $true
+ }
+ catch {
+ Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
+ return $false
+ }
+}
+
+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
new file mode 100644
index 0000000..0de88e4
--- /dev/null
+++ b/utils/modules/Engine/ReleaseSupport.psm1
@@ -0,0 +1,268 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+$modulesDir = Split-Path $PSScriptRoot -Parent
+
+if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
+ $loggingModulePath = Join-Path $modulesDir "Logging.psm1"
+ if (Test-Path $loggingModulePath -PathType Leaf) {
+ Import-Module $loggingModulePath -Force
+ }
+}
+
+if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
+ $gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1"
+ if (Test-Path $gitToolsModulePath -PathType Leaf) {
+ Import-Module $gitToolsModulePath -Force
+ }
+}
+
+if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) {
+ $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
+ if (Test-Path $pluginSupportModulePath -PathType Leaf) {
+ Import-Module $pluginSupportModulePath -Force
+ }
+}
+
+if (-not (Get-Command Resolve-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)) {
+ Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)."
+ foreach ($line in @([string]$gitStatus -split "`r?`n")) {
+ if (-not [string]::IsNullOrWhiteSpace($line)) {
+ Write-Log -Level "WARN" -Message " $line"
+ }
+ }
+ return
+ }
+
+ Write-Log -Level "OK" -Message " Working directory is clean."
+}
+
+function Initialize-ReleaseStageContext {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$SharedSettings,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ArtifactsDirectory
+ )
+
+ if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) {
+ if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
+ Set-EngineState -Context $SharedSettings -Name 'releaseDir' -Value $ArtifactsDirectory
+ }
+ else {
+ $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force
+ }
+ }
+}
+
+function New-EngineContext {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ScriptDir,
+
+ [Parameter(Mandatory = $true)]
+ [string]$SrcDir,
+
+ [Parameter(Mandatory = $false)]
+ [psobject]$Settings,
+
+ [Parameter(Mandatory = $false)]
+ [psobject]$ExtensionData
+ )
+
+ $releaseRelative = '..\..\..\releases'
+ $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative))
+
+ $currentBranch = Get-CurrentBranch
+
+ $releaseBranches = @()
+ foreach ($p in $Plugins) {
+ if (-not $p.enabled) { continue }
+ if ([string]$p.name -ne 'ReleasePublishGuard') { continue }
+ foreach ($b in (Get-PluginBranches -Plugin $p)) {
+ $releaseBranches += $b
+ }
+ }
+ $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
+ if ($releaseBranches.Count -eq 0) {
+ foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) {
+ if (-not $p.enabled) { continue }
+ foreach ($b in (Get-PluginBranches -Plugin $p)) {
+ $releaseBranches += $b
+ }
+ }
+ $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
+ }
+ if ($releaseBranches.Count -eq 0) {
+ $releaseBranches = @('main')
+ }
+
+ $isNonReleaseBranch = -not ($releaseBranches -contains $currentBranch)
+
+ Assert-WorkingTreeClean
+
+ $context = [pscustomobject]@{
+ scriptDir = $ScriptDir
+ srcDir = $SrcDir
+ utilsDir = $SrcDir
+ currentBranch = $currentBranch
+ artifactsDirectory = $artifactsDirectory
+ isNonReleaseBranch = $isNonReleaseBranch
+ releaseBranches = $releaseBranches
+ 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 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$EngineContext
+ )
+
+ if ($EngineContext.releaseBranches.Count -gt 0) {
+ return $EngineContext.releaseBranches[0]
+ }
+
+ return "main"
+}
+
+Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch
diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1
new file mode 100644
index 0000000..b49b60b
--- /dev/null
+++ b/utils/modules/Engine/TestSupport.psm1
@@ -0,0 +1,59 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+$modulesDir = Split-Path $PSScriptRoot -Parent
+
+if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
+ $loggingModulePath = Join-Path $modulesDir "Logging.psm1"
+ if (Test-Path $loggingModulePath -PathType Leaf) {
+ Import-Module $loggingModulePath -Force
+ }
+}
+
+if (-not (Get-Command 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)]
+ [string]$ScriptDir,
+
+ [Parameter(Mandatory = $true)]
+ [string]$SrcDir,
+
+ [Parameter(Mandatory = $false)]
+ [psobject]$Settings,
+
+ [Parameter(Mandatory = $false)]
+ [psobject]$ExtensionData
+ )
+
+ $context = [pscustomobject]@{
+ scriptDir = $ScriptDir
+ srcDir = $SrcDir
+ utilsDir = $SrcDir
+ 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/src/modules/GitTools.psm1 b/utils/modules/GitTools.psm1
similarity index 86%
rename from src/modules/GitTools.psm1
rename to utils/modules/GitTools.psm1
index be56c94..e13050c 100644
--- a/src/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)"
diff --git a/src/modules/Logging.psm1 b/utils/modules/Logging.psm1
similarity index 100%
rename from src/modules/Logging.psm1
rename to utils/modules/Logging.psm1
diff --git a/src/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1
similarity index 58%
rename from src/modules/ScriptConfig.psm1
rename to utils/modules/ScriptConfig.psm1
index 26bd953..3cdbb35 100644
--- a/src/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/src/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1
similarity index 50%
rename from src/modules/TestRunner.psm1
rename to utils/modules/TestRunner.psm1
index 395acf4..b1935e2 100644
--- a/src/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
@@ -151,11 +173,12 @@ function Invoke-TestsWithCoverage {
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
+ Import-ExternalCommandSupportInternal
if ($Silent) {
- $null = & dotnet @dotnetArgs 2>&1
+ $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput
}
else {
- & dotnet @dotnetArgs
+ Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default
}
$testExitCode = $LASTEXITCODE
@@ -331,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) {
@@ -389,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/src/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1
similarity index 100%
rename from src/plugins/DotNet/DotNetCleanupArtifacts.psm1
rename to utils/plugins/DotNet/DotNetCleanupArtifacts.psm1
diff --git a/src/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1
similarity index 59%
rename from src/plugins/DotNet/DotNetCreateArchive.psm1
rename to utils/plugins/DotNet/DotNetCreateArchive.psm1
index 92b34bd..e921189 100644
--- a/src/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/src/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1
similarity index 55%
rename from src/plugins/DotNet/DotNetNuGet.psm1
rename to utils/plugins/DotNet/DotNetNuGet.psm1
index ecb2eb0..ef08a81 100644
--- a/src/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/src/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1
similarity index 61%
rename from src/plugins/DotNet/DotNetPack.psm1
rename to utils/plugins/DotNet/DotNetPack.psm1
index bffc191..5929045 100644
--- a/src/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,12 +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
}
- $packageProjectPath = $null
- $releaseArchiveInputs = @()
Assert-Command dotnet
@@ -79,50 +80,19 @@ function Invoke-Plugin {
throw "dotnet pack failed for $packageProjectPath."
}
- # dotnet pack can leave older packages in the artifacts directory.
- # Pick the newest file matching the current version rather than assuming a clean folder.
- $packageFile = $null
- $newestNupkgWrite = [datetime]::MinValue
- $nupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.nupkg"
- foreach ($candidate in $nupkgCandidates) {
- if (($candidate.Name -like "*$version*.nupkg") -and ($candidate.Name -notlike "*.symbols.nupkg") -and ($candidate.Name -notlike "*.snupkg")) {
- if ($candidate.LastWriteTime -gt $newestNupkgWrite) {
- $newestNupkgWrite = $candidate.LastWriteTime
- $packageFile = $candidate
- }
- }
- }
+ $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $outputDir -Version $version
- if (-not $packageFile) {
- throw "Could not locate generated NuGet package for version $version in: $outputDir"
- }
-
- Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
- $releaseArchiveInputs = @($packageFile.FullName)
-
- $symbolsPackageFile = $null
- $newestSnupkgWrite = [datetime]::MinValue
- $snupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.snupkg"
- foreach ($candidate in $snupkgCandidates) {
- if ($candidate.Name -like "*$version*.snupkg") {
- if ($candidate.LastWriteTime -gt $newestSnupkgWrite) {
- $newestSnupkgWrite = $candidate.LastWriteTime
- $symbolsPackageFile = $candidate
- }
- }
- }
-
- if ($symbolsPackageFile) {
- Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)"
- $releaseArchiveInputs += $symbolsPackageFile.FullName
+ 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."
}
- $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $packageFile -Force
- $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force
- $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force
+ 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/src/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1
similarity index 74%
rename from src/plugins/DotNet/DotNetPublish.psm1
rename to utils/plugins/DotNet/DotNetPublish.psm1
index 84c4ec2..d2dacba 100644
--- a/src/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
new file mode 100644
index 0000000..3cb01fd
--- /dev/null
+++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1
@@ -0,0 +1,96 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.SYNOPSIS
+ Loads release version from an SDK-style .csproj into shared context.
+
+.DESCRIPTION
+ 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)) {
+ $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-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)]
+ $Settings
+ )
+
+ Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
+ Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
+
+ $shared = $Settings.context
+ $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."
+ }
+
+ 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, Get-PluginMetadata
diff --git a/src/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1
similarity index 71%
rename from src/plugins/DotNet/DotNetTest.psm1
rename to utils/plugins/DotNet/DotNetTest.psm1
index 188a825..b8bd09d 100644
--- a/src/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)) {
@@ -68,6 +69,7 @@ function Invoke-Plugin {
}
if ($testResultsDir) {
$invokeTestParams.ResultsDirectory = $testResultsDir
+ $invokeTestParams.KeepResults = $true
}
$testResult = Invoke-TestsWithCoverage @invokeTestParams
@@ -76,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/src/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1
similarity index 100%
rename from src/plugins/Npm/NpmBuild.psm1
rename to utils/plugins/Npm/NpmBuild.psm1
diff --git a/src/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1
similarity index 66%
rename from src/plugins/Npm/NpmJestTest.psm1
rename to utils/plugins/Npm/NpmJestTest.psm1
index 82803c7..e89f604 100644
--- a/src/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/src/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1
similarity index 62%
rename from src/plugins/Npm/NpmPublish.psm1
rename to utils/plugins/Npm/NpmPublish.psm1
index 7ff8259..db6b0ec 100644
--- a/src/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/src/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1
similarity index 84%
rename from src/plugins/Npm/NpmReleaseVersion.psm1
rename to utils/plugins/Npm/NpmReleaseVersion.psm1
index 3020c78..ad48e46 100644
--- a/src/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/src/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1
similarity index 58%
rename from src/plugins/Platform/CoverageBadges.psm1
rename to utils/plugins/Platform/CoverageBadges.psm1
index 36ba26a..2187b9c 100644
--- a/src/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/src/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1
similarity index 71%
rename from src/plugins/Platform/GitHub.psm1
rename to utils/plugins/Platform/GitHub.psm1
index 9af816c..61773e0 100644
--- a/src/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/src/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1
similarity index 83%
rename from src/plugins/Platform/QualityGate.psm1
rename to utils/plugins/Platform/QualityGate.psm1
index da18d38..a81dfde 100644
--- a/src/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/src/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1
similarity index 90%
rename from src/plugins/Platform/ReleasePublishGuard.psm1
rename to utils/plugins/Platform/ReleasePublishGuard.psm1
index adfb262..43b6dfe 100644
--- a/src/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/src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
similarity index 100%
rename from src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
rename to utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
diff --git a/src/tools/Force-AmendTaggedCommit/scriptSettings.json b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json
similarity index 100%
rename from src/tools/Force-AmendTaggedCommit/scriptSettings.json
rename to utils/tools/Force-AmendTaggedCommit/scriptSettings.json
diff --git a/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1
similarity index 74%
rename from src/tools/Update-RepoUtils/Update-RepoUtils.ps1
rename to utils/tools/Update-RepoUtils/Update-RepoUtils.ps1
index 80082c3..00b3ce0 100644
--- a/src/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/src/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json
similarity index 89%
rename from src/tools/Update-RepoUtils/scriptSettings.json
rename to utils/tools/Update-RepoUtils/scriptSettings.json
index 6ad1a7f..ca20f5c 100644
--- a/src/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"
]
}
}