(chore): update utils layout and dapr deps

This commit is contained in:
Maksym Sadovnychyy 2026-06-28 11:34:51 +02:00
parent 13e31855f6
commit d55e3af7d2
66 changed files with 3577 additions and 1561 deletions

118
.editorconfig Normal file
View File

@ -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

6
.gitignore vendored
View File

@ -264,3 +264,9 @@ __pycache__/
**/*docker-compose/LetsEncryptServer/acme
**/*docker-compose/LetsEncryptServer/cache
#Custom
![Uu]tils/**
# Generated test reports (RepoUtils)
test-results/
utils/tests/results/

View File

@ -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.

View File

@ -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.

View File

@ -1,6 +1,8 @@
# MaksIT.Dapr
![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-62.1%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-50%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-60%25-green)
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.

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 50%">
<title>Branch Coverage: 50%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="147.5" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="40" height="20" fill="#a4a61d"/>
<rect width="147.5" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">50%</text>
<text x="127.5" y="14" fill="#fff">50%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 62.1%">
<title>Line Coverage: 62.1%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#97ca00"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">62.1%</text>
<text x="115.75" y="14" fill="#fff">62.1%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Method Coverage: 60%">
<title>Method Coverage: 60%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="147.5" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="40" height="20" fill="#97ca00"/>
<rect width="147.5" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
<text x="127.5" y="14" fill="#fff">60%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
pause

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
pause

View File

@ -12,7 +12,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
<PackageReference Include="xunit.v3" Version="3.*" />

View File

@ -7,7 +7,7 @@
<!-- NuGet package metadata -->
<PackageId>MaksIT.Dapr</PackageId>
<Version>2.0.0</Version>
<Version>2.0.1</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Dapr</Product>
@ -21,11 +21,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.17.9" />
<PackageReference Include="Dapr.AspNetCore" Version="1.17.9" />
<PackageReference Include="Dapr.Workflow" Version="1.17.9" />
<PackageReference Include="MaksIT.Core" Version="1.6.7" />
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.18.4" />
<PackageReference Include="Dapr.AspNetCore" Version="1.18.4" />
<PackageReference Include="Dapr.Workflow" Version="1.18.4" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="MaksIT.Results" Version="2.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -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 <Version>)
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
#>
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function Resolve-RelativePaths {
param(
[Parameter(Mandatory = $true)]
[object]$Value,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
if ($null -eq $Value) {
return @()
}
$rawPaths = @()
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
$rawPaths += $Value
}
else {
$rawPaths += $Value
}
$resolved = @()
foreach ($p in $rawPaths) {
if ([string]::IsNullOrWhiteSpace([string]$p)) {
continue
}
$resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p)))
}
return @($resolved)
}
function Get-CsprojPropertyValue {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
# SDK-style .csproj files can have multiple PropertyGroup nodes.
# Use the first group that defines the requested property.
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
}
return $null
}
function Get-CsprojVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..."
$projectVersions = @{}
foreach ($projectPath in $ProjectFiles) {
if (-not (Test-Path $projectPath -PathType Leaf)) {
Write-Error "Project file not found at: $projectPath"
exit 1
}
if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") {
Write-Error "Configured project file is not a .csproj file: $projectPath"
exit 1
}
[xml]$csproj = Get-Content $projectPath
$version = Get-CsprojPropertyValue -Csproj $csproj -PropertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projectPath"
exit 1
}
$projectVersions[$projectPath] = $version
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version"
}
return $projectVersions
}
function Resolve-DotNetReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir)
if ($projectFiles.Count -eq 0) {
Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with <Version>)."
exit 1
}
$projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
version = $version
source = 'DotNetReleaseVersion'
}
}
function Resolve-NpmReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure an NpmReleaseVersion plugin in scriptSettings.json with packageJsonPath."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir)
if ($packageJsonPaths.Count -eq 0) {
Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath."
exit 1
}
$packageJsonPath = $packageJsonPaths[0]
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath"
exit 1
}
Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..."
$json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'."
exit 1
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver."
exit 1
}
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version"
return [pscustomobject]@{
version = $version
source = 'NpmReleaseVersion'
}
}
function Resolve-ReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false })
$npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false })
if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) {
Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both."
exit 1
}
if ($dotnetPlugin.Count -gt 0) {
return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
if ($npmPlugin.Count -gt 0) {
return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json."
exit 1
}
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
@echo off
setlocal
pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %*
exit /b %ERRORLEVEL%

View File

@ -0,0 +1,4 @@
@echo off
setlocal
pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %*
exit /b %ERRORLEVEL%

View File

@ -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
}

View File

@ -0,0 +1 @@

View File

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

View File

@ -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"
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()
}
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)"

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
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
}
elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$archiveInputs = @($sharedSettings.packageFile.FullName)
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$archiveInputs += $sharedSettings.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

View File

@ -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
}
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
if ([string]::IsNullOrWhiteSpace($nugetApiKey)) {
throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun."
Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource"
return
}
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

View File

@ -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

View File

@ -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

View File

@ -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 <Version> 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: <Version> 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 <Version>) 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

View File

@ -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)%"

View File

@ -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!"

View File

@ -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

View File

@ -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

View File

@ -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'..."
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

View File

@ -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

View File

@ -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

View File

@ -6,7 +6,11 @@
Coverage badge plugin for the test engine.
.DESCRIPTION
Reads line/branch/method coverage from shared engine context and writes SVG badges.
Reads line/branch/method coverage from shared engine context.
badgeFormat "svg" (default): writes SVG files under badgesDir.
badgeFormat "shields": updates readmePath (string or array) with img.shields.io markdown
(no local assets).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -80,6 +84,80 @@ function New-BadgeSvgInternal {
"@
}
function New-ShieldsIoBadgeUrlInternal {
param(
[Parameter(Mandatory = $true)]
[string]$Label,
[Parameter(Mandatory = $true)]
[double]$Percentage,
[Parameter(Mandatory = $true)]
[string]$Color
)
$labelToken = ($Label -replace ' ', '%20')
$valueToken = "$Percentage%25"
return "https://img.shields.io/badge/$labelToken-$valueToken-$Color"
}
function New-ShieldsIoBadgeMarkdownInternal {
param(
[Parameter(Mandatory = $true)]
[string]$Label,
[Parameter(Mandatory = $true)]
[double]$Percentage,
[Parameter(Mandatory = $true)]
[string]$Color
)
$url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color
return "![$Label]($url)"
}
function Update-ReadmeShieldsBadgesInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ReadmePath,
[Parameter(Mandatory = $true)]
[object[]]$Badges,
[Parameter(Mandatory = $true)]
[hashtable]$Metrics,
[Parameter(Mandatory = $true)]
[psobject]$Thresholds
)
if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) {
throw "CoverageBadges readmePath not found: $ReadmePath"
}
$content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8
foreach ($badge in @($Badges)) {
$metricValue = $Metrics[[string]$badge.metric]
if ($null -eq $metricValue) {
throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'."
}
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds
$markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color
# Horizontal whitespace only; optional CR for CRLF. Do not let \s* eat the following blank line.
$pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)[^\S\r\n]*\r?$"
if ($content -notmatch $pattern) {
throw "README badge line not found for label '$($badge.label)' in: $ReadmePath"
}
$content = [regex]::Replace($content, $pattern, $markdown)
}
$content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline
}
function Get-CoverageMetricsFromSharedContext {
param(
[Parameter(Mandatory = $true)]
@ -131,17 +209,9 @@ function Invoke-Plugin {
$scriptDir = $sharedSettings.scriptDir
$metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings
$badgesDir = $sharedSettings.badgesDir
if ($pluginSettings.badgesDir) {
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
$badgesDir = $badgesDirs[0]
}
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json."
}
if (-not (Test-Path $badgesDir)) {
New-Item -ItemType Directory -Path $badgesDir | Out-Null
$badgeFormat = 'svg'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) {
$badgeFormat = [string]$pluginSettings.badgeFormat
}
$thresholds = $pluginSettings.colorThresholds
@ -158,10 +228,56 @@ function Invoke-Plugin {
Write-Log -Level "STEP" -Message "Generating coverage badges..."
if ($badgeFormat -eq 'shields') {
$readmePathSetting = $null
if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) {
$readmePathSetting = $sharedSettings.readmePath
}
if ($pluginSettings.PSObject.Properties.Name -contains 'readmePath' -and $pluginSettings.readmePath) {
$readmePathSetting = $pluginSettings.readmePath
}
$readmePaths = @()
if ($null -ne $readmePathSetting) {
$readmePaths = @(Resolve-RelativePaths -Value $readmePathSetting -BasePath $scriptDir)
}
if ($readmePaths.Count -eq 0) {
throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json."
}
foreach ($readmePath in $readmePaths) {
Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds
Write-Log -Level "OK" -Message "README shields updated: $readmePath"
}
foreach ($badge in @($pluginSettings.badges)) {
$metricValue = $metrics[[string]$badge.metric]
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds
Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)"
}
Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs."
return
}
$badgesDir = $sharedSettings.badgesDir
if ($pluginSettings.badgesDir) {
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
$badgesDir = $badgesDirs[0]
}
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json."
}
if (-not (Test-Path $badgesDir)) {
New-Item -ItemType Directory -Path $badgesDir | Out-Null
}
foreach ($badge in @($pluginSettings.badges)) {
$metricValue = $metrics[[string]$badge.metric]
if ($null -eq $metricValue) {
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'."
$badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label }
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'."
}
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds

View File

@ -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

View File

@ -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

View File

@ -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 <report>): $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

View File

@ -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..."

View File

@ -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
)
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

View File

@ -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
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
}
if (-not $dryRun) {
Copy-Item -Path $file.FullName -Destination $backupPath -Force
}
}
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 "========================================"

View File

@ -11,6 +11,9 @@
"skippedRelativeDirectories": [
"engines/release/custom",
"engines/test/custom"
],
"omittedRelativeDirectories": [
"tests"
]
}
}