(build): bump to 1.6.8 and update nuget dependencies

This commit is contained in:
Maksym Sadovnychyy 2026-06-27 22:13:10 +02:00
parent 4a6fdd1f3f
commit b5f31faf9e
48 changed files with 2805 additions and 1302 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 ...; } (IDE0022IDE0027)
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

@ -262,4 +262,8 @@ __pycache__/
*.pyc
#Custom
![Uu]tils/**
![Uu]tils/**
# Generated test reports (RepoUtils)
test-results/
utils/tests/results/

View File

@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.8] - 2026-06-27
### Changed
- Updated NuGet dependencies: Microsoft.Extensions.* and System.Threading.RateLimiting 10.0.8 → 10.0.9, Microsoft.AspNetCore.* 2.3.10 → 2.3.11, Microsoft.IdentityModel.Tokens and System.IdentityModel.Tokens.Jwt 8.19.x → 8.19.1, Microsoft.NET.Test.Sdk 18.6.0 → 18.7.0
## [1.6.7] - 2026-06-02
### Fixed

View File

@ -101,162 +101,31 @@ This project follows [Semantic Versioning](https://semver.org/):
## Release Process
The release process is automated via PowerShell scripts in the `src/scripts/` directory.
Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)).
### Prerequisites
- Docker Desktop running (for Linux tests)
- GitHub CLI (`gh`) installed
- Environment variables configured:
- `NUGET_MAKS_IT` - NuGet.org API key
- `GITHUB_MAKS_IT_COM` - GitHub Personal Access Token (needs `repo` scope)
- .NET SDK, PowerShell 7+, Git, GitHub CLI (`gh`)
- Environment variables (names match logical secrets in `scriptSettings.json`):
- **`GitHub`** — GitHub token (`repo` scope)
- **`NuGet`** — NuGet.org API key
### Release Scripts Overview
| Entry | Purpose |
|-------|---------|
| `utils\Invoke-TestEngine.bat` | Tests and coverage badges |
| `utils\Invoke-ReleasePackage-Single.bat` | Release (build, test, pack, publish) |
| `utils\Update-RepoUtils.bat` | Sync engines from maksit-repoutils |
| `utils\Force-AmendTaggedCommit.bat` | Amend last tagged commit |
| Script | Purpose |
|--------|---------|
| `Generate-Changelog.ps1` | AI-assisted changelog generation and license year update |
| `Release-NuGetPackage.ps1` | Build, test, and publish to NuGet.org and GitHub |
| `Force-AmendTaggedCommit.ps1` | Fix mistakes in tagged commits |
### Workflow
### Release Workflow
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
2. Commit, tag `vX.Y.Z` on `main`
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
1. **Update version** in `MaksIT.Core/MaksIT.Core.csproj`
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
2. **Generate changelog** (uses AI with Ollama if available):
```powershell
cd src/scripts
.\Generate-Changelog.ps1 # Updates CHANGELOG.md and LICENSE.md year
.\Generate-Changelog.ps1 -DryRun # Preview without changes
```
3. **Review and commit** all changes:
```bash
git add -A
git commit -m "(chore): release v1.x.x"
```
4. **Create version tag**:
```bash
git tag v1.x.x
```
5. **Run release script**:
```powershell
cd src/scripts
.\Release-NuGetPackage.ps1 # Full release
.\Release-NuGetPackage.ps1 -DryRun # Test without publishing
```
---
### Generate-Changelog.ps1
AI-assisted changelog generation using a 3-pass LLM pipeline with Ollama.
**What it does:**
1. Analyzes uncommitted changes and converts them to changelog items
2. Consolidates similar items and removes duplicates
3. Formats output in Keep a Changelog format
4. Updates LICENSE.md copyright year if needed
**Usage:**
```powershell
.\Generate-Changelog.ps1 # Generate and update files
.\Generate-Changelog.ps1 -DryRun # Preview without changes
```
**Configuration:** `changelogsettings.json`
- LLM models for each pass
- Prompt templates
- File paths
---
### Release-NuGetPackage.ps1
Builds, tests, and publishes the package to NuGet.org and GitHub.
**The script is IDEMPOTENT** - you can safely re-run it if any step fails. It will skip already-completed steps and only create what's missing.
**What it does:**
1. Validates prerequisites and environment
2. Checks if already released on NuGet.org (skips if exists)
3. Checks if GitHub release exists (skips if exists)
4. Runs security vulnerability scan
5. Builds and tests on Windows
6. Builds and tests on Linux (via Docker)
7. Analyzes code coverage
8. Creates NuGet package (.nupkg and .snupkg)
9. Pushes to NuGet.org
10. Creates GitHub release with assets
**Usage:**
```powershell
.\Release-NuGetPackage.ps1 # Full release
.\Release-NuGetPackage.ps1 -DryRun # Test without publishing
```
**Idempotent behavior:**
| Target | Already Released | Action |
|--------|-----------------|--------|
| NuGet.org | Yes | Skip |
| NuGet.org | No | Publish |
| GitHub | Yes | Skip |
| GitHub | No | Create release |
**Validation checks:**
- Version source: Reads latest version from `CHANGELOG.md`
- Tag required: Must have a tag matching the changelog version (e.g., `v1.2.3`)
- Branch validation: Tag must be on configured branch (default: `main`)
- Clean working directory: No uncommitted changes allowed
**Configuration:** `scriptsettings.json`
---
### Force-AmendTaggedCommit.ps1
Fixes mistakes in the last tagged commit by amending it and force-pushing.
**When to use:**
- You noticed an error after committing and tagging
- Need to add forgotten files to the release commit
- Need to fix a typo in the release
**What it does:**
1. Verifies the last commit has an associated tag
2. Stages all pending changes
3. Amends the latest commit (keeps existing message)
4. Deletes and recreates the tag on the amended commit
5. Force pushes the branch and tag to origin
**Usage:**
```powershell
.\Force-AmendTaggedCommit.ps1 # Amend and force push
.\Force-AmendTaggedCommit.ps1 -DryRun # Preview without changes
```
**Warning:** This rewrites history. Only use on commits that haven't been pulled by others.
---
### Fixing a Failed Release
If the release partially failed (e.g., NuGet succeeded but GitHub failed):
1. **Just re-run the release script:**
```powershell
.\Release-NuGetPackage.ps1
```
The script will skip NuGet (already released) and create the GitHub release.
2. **If you need to fix the commit content:**
```powershell
# Make your fixes, then:
.\Force-AmendTaggedCommit.ps1
.\Release-NuGetPackage.ps1
```
Configuration: `utils/engines/release/scriptSettings.json`, `utils/engines/test/scriptSettings.json`
## License

View File

@ -1,6 +1,8 @@
# MaksIT.Core Library Documentation
![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-60.1%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-49.9%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-69.2%25-green)
## Table of Contents

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.9%">
<title>Branch Coverage: 49.9%</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="150" 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="42.5" height="20" fill="#a4a61d"/>
<rect width="150" 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="128.75" y="15" fill="#010101" fill-opacity=".3">49.9%</text>
<text x="128.75" y="14" fill="#fff">49.9%</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: 60.1%">
<title>Line Coverage: 60.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">60.1%</text>
<text x="115.75" y="14" fill="#fff">60.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="150" height="20" role="img" aria-label="Method Coverage: 69.2%">
<title>Method Coverage: 69.2%</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="150" 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="42.5" height="20" fill="#97ca00"/>
<rect width="150" 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="128.75" y="15" fill="#010101" fill-opacity=".3">69.2%</text>
<text x="128.75" y="14" fill="#fff">69.2%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -14,8 +14,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -12,7 +12,7 @@
<!-- NuGet package metadata -->
<PackageId>MaksIT.Core</PackageId>
<Version>1.6.7</Version>
<Version>1.6.8</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Core</Product>
@ -42,16 +42,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.2" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.8" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.9" />
</ItemGroup>
<ItemGroup>

View File

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

View File

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

View File

@ -4,23 +4,62 @@
<#
.SYNOPSIS
Plugin-driven release engine entry script.
.NOTES
Per-plugin dryRun on mutatesRemote plugins validates without remote mutation.
There is no engine-wide dryRun switch set dryRun on each plugin as needed.
#>
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
# Keep a plain param block so unbound launcher arguments remain in $args for
# optional Initialize-ReleaseExtension (advanced binding would reject them).
param()
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1')
Import-EngineModules -Engine Release
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$releaseExtension = $null
if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) {
$releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList $args
}
$settings = if ($null -ne $releaseExtension) {
$releaseExtension.Settings
}
else {
Get-ScriptSettings -ScriptDir $PSScriptRoot
}
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
$releaseBanner = if ($null -ne $releaseExtension) {
$releaseExtension.StepBanner
}
else {
'RELEASE ENGINE'
}
Write-Log -Level 'STEP' -Message '=================================================='
Write-Log -Level 'STEP' -Message 'RELEASE ENGINE'
Write-Log -Level 'STEP' -Message $releaseBanner
Write-Log -Level 'STEP' -Message '=================================================='
$plugins = $configuredPlugins
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings
$newContextParams = @{
Plugins = $plugins
ScriptDir = $PSScriptRoot
SrcDir = $srcDir
Settings = $settings
}
if ($null -ne $releaseExtension) {
$newContextParams['ExtensionData'] = $releaseExtension.ContextData
}
$engineContext = New-EngineContext @newContextParams
Write-Log -Level 'OK' -Message 'All pre-flight checks passed!'
$sharedPluginSettings = $engineContext
@ -34,15 +73,14 @@ else {
for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
$plugin = $plugins[$pluginIndex]
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -WriteLogs:$false) {
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version
if ((Test-IsPublishPlugin -Plugin $plugin -EngineDirectory $PSScriptRoot) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -WriteLogs:$false) {
Initialize-ReleaseStageContext -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory
$releaseStageInitialized = $true
}
}
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false
if (-not $pluginSucceeded) {
$releaseHadPluginFailures = $true
break
@ -52,7 +90,7 @@ else {
if (-not $releaseStageInitialized) {
$noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' }
Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled publish plugins reached, or none runnable).'
Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).'
}
Write-Log -Level 'OK' -Message '=================================================='

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
"description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DotNetNuGet/DotNetDockerPush/DotNetHelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).",
"description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DotNetNuGet/DockerImagePush/HelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).",
"plugins": [
{
"name": "DotNetReleaseVersion",
@ -35,7 +35,7 @@
"projectFiles": [
"..\\..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
],
"artifactsDir": "..\\..\\..\\release"
"artifactsDir": "..\\..\\..\\releases"
},
{
"name": "DotNetCreateArchive",
@ -61,7 +61,7 @@
"name": "GitHub",
"stageLabel": "release",
"enabled": true,
"githubToken": "GITHUB_MAKS_IT_COM",
"githubSecret": "GitHub",
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
@ -70,7 +70,7 @@
"name": "DotNetNuGet",
"stageLabel": "release",
"enabled": true,
"nugetApiKey": "NUGET_MAKS_IT",
"nugetSecret": "NuGet",
"source": "https://api.nuget.org/v3/index.json"
},
{
@ -92,7 +92,7 @@
"name": "NpmPublish",
"stageLabel": "release",
"enabled": false,
"npmApiKey": "NPMJS_MAKS_IT",
"npmSecret": "Npm",
"registry": "https://registry.npmjs.org",
"access": "public",
"workspaceRoot": "..\\..\\..\\src",
@ -101,35 +101,6 @@
"@scope/example-core"
]
},
{
"name": "DotNetDockerPush",
"stageLabel": "release",
"enabled": false,
"registryUrl": "cr.maks-it.com",
"credentialsEnvVar": "CR_MAKS_IT",
"projectName": "my-service",
"contextPath": "..\\..\\..\\src",
"pushLatest": true,
"images": [
{
"service": "api",
"dockerfile": "MyService.Api/Dockerfile",
"versionEnvFiles": [
"MyService.WebUI/.env",
"MyService.WebUI/.env.prod"
]
}
]
},
{
"name": "DotNetHelmPush",
"stageLabel": "release",
"enabled": false,
"chartPath": "..\\..\\..\\helm\\my-service",
"ociRepository": "oci://cr.maks-it.com/charts",
"credentialsEnvVar": "CR_MAKS_IT",
"pushLatest": false
},
{
"name": "DotNetCleanupArtifacts",
"stageLabel": "release",
@ -141,54 +112,6 @@
"*.zip"
]
}
],
"_comments": {
"plugins": {
"name": "Plugin module name (for example, DotNetPack -> plugins/DotNet/DotNetPack.psm1). Lookup: engines/release/custom, then plugins/Platform, DotNet, Npm.",
"stageLabel": "Execution phase: test, qualityGate, build, or release (lowercase). Plugin failures stop the run and report RELEASE FAILED.",
"enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"DotNetReleaseVersion": "Reads <Version> from the first projectFiles entry; writes shared context version. ReleasePublishGuard checks tag on HEAD matches when tagVersionMustMatchDotNetRelease is true.",
"project": "DotNetTest plugin only. Path to one test project directory, relative to the script folder (omit if using projects).",
"projects": "DotNetTest plugin only. Array of test project paths relative to the engine folder (engines/release or engines/test). If several projects and resultsDir is omitted, uses TestResults next to the engine script.",
"resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.",
"projectFiles": "DotNetReleaseVersion: version source (first .csproj). DotNetPack / QualityGate: which projects to pack or scan (relative to engines/release).",
"artifactsDir": "DotNetPack: output directory for packages (relative to engines/release). Engine default artifacts root is ..\\..\\..\\release when omitted here.",
"coverageThreshold": "QualityGate: line coverage threshold percent (0 disables). Requires qualityLineCoverage, coverageLineRate, or testResult.LineRate on shared context when > 0.",
"scanVulnerabilities": "QualityGate: omit or true to run dotnet list package --vulnerable on projectFiles; false skips (no projectFiles needed).",
"failOnVulnerabilities": "QualityGate: when scanVulnerabilities is true, fail if vulnerable packages are found (default true).",
"githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.",
"repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.",
"releaseNotesFile": "GitHub plugin: path to CHANGELOG.md (relative to engines/release). Top entry must use Keep a Changelog form ## [semver] - YYYY-MM-DD (parsed by ChangelogSupport).",
"releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.",
"zipNamePattern": "DotNetCreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"nugetApiKey": "DotNetNuGet plugin only. Environment variable name containing the NuGet API key.",
"source": "DotNetNuGet plugin only. Feed URL passed to dotnet nuget push.",
"includePatterns": "DotNetCleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).",
"excludePatterns": "DotNetCleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip']).",
"registryUrl": "DotNetDockerPush: registry host without scheme.",
"credentialsEnvVar": "DotNetDockerPush / DotNetHelmPush: environment variable name whose value is Base64(username:password).",
"projectName": "DotNetDockerPush: image path segment after registry.",
"contextPath": "DotNetDockerPush: docker build context, relative to engines/release folder.",
"pushLatest": "DotNetDockerPush: also push :latest (after bare semver e.g. :3.3.4 and :v3.3.4). DotNetHelmPush: after helm push, oras copy chart to :latest (requires oras CLI on PATH; set false to skip).",
"images": "DotNetDockerPush: [{ service, dockerfile, contextPath?, versionEnvFiles? }]. dockerfile and versionEnvFiles are relative to the image contextPath when set, otherwise plugin contextPath.",
"versionEnvFiles": "DotNetDockerPush image option. Temporarily replace VITE_APP_VERSION in listed files (relative to the image build context) with shared.version during docker build, then restore original files.",
"chartPath": "DotNetHelmPush: directory containing Chart.yaml, relative to engines/release (product repo, e.g. ..\\\\..\\\\..\\\\helm\\\\my-service). Keep version/appVersion as placeholders in git (e.g. 0.0.0); DotNetHelmPush overwrites them with bare semver from DotNetReleaseVersion (shared.version, e.g. 3.3.4, no v) before helm package/push; falls back to stripping v from shared.tag if version is missing.",
"ociRepository": "DotNetHelmPush: OCI registry URL for helm push (e.g. oci://cr.maks-it.com/charts).",
"branches": "ReleasePublishGuard: allowed branches for publish (omit or [\"*\"] = any). Do not put branches on GitHub/DotNetNuGet/DotNetDockerPush/DotNetHelmPush/NpmPublish.",
"requireExactTagOnHead": "ReleasePublishGuard: require git describe --tags --exact-match HEAD (vX.Y.Z).",
"tagVersionMustMatchDotNetRelease": "ReleasePublishGuard: tag semver must equal DotNetReleaseVersion when true.",
"whenRequirementsNotMet": "ReleasePublishGuard: skip (suppress publish plugins) or fail (exit 1).",
"requireCleanWorkingTree": "ReleasePublishGuard: block publish if git status is not clean.",
"ensureTagOnRemote": "ReleasePublishGuard: push tag to remoteName if missing.",
"remoteName": "ReleasePublishGuard: git remote for tag push (default origin).",
"NpmReleaseVersion": "Reads version from packageJsonPath (workspace root package.json). syncWorkspaceVersions aligns packages/*/package.json.",
"NpmBuild": "npm ci/install + npm run buildScript in workspaceRoot.",
"npmApiKey": "NpmPublish: environment variable name for npm automation token (e.g. NPMJS_MAKS_IT).",
"publishOrder": "NpmPublish: workspace package names in dependency order.",
"registry": "NpmPublish: npm registry URL (default https://registry.npmjs.org).",
"access": "NpmPublish: npm publish --access (default public).",
"tagVersionMustMatchReleaseVersion": "ReleasePublishGuard: tag semver must equal NpmReleaseVersion or DotNetReleaseVersion when true.",
"tagVersionMustMatchNpmRelease": "ReleasePublishGuard: alias of tagVersionMustMatchReleaseVersion for npm repos."
}
}
]
}

View File

@ -2,9 +2,6 @@
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings",
"description": "Template: plugin-driven tests and coverage badges. Product repos override this file via Update-RepoUtils preserve.",
"paths": {
"badgesDir": "..\\..\\..\\assets\\badges"
},
"plugins": [
{
"name": "DotNetTest",
@ -12,7 +9,8 @@
"enabled": true,
"projects": [
"..\\..\\..\\src\\MaksIT.Core.Tests"
]
],
"resultsDir": "..\\..\\..\\test-results"
},
{
"name": "QualityGate",
@ -25,20 +23,18 @@
"name": "CoverageBadges",
"stageLabel": "report",
"enabled": true,
"badgesDir": "..\\..\\..\\assets\\badges",
"badgeFormat": "shields",
"readmePath": "..\\..\\..\\README.md",
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
@ -53,12 +49,8 @@
}
}
],
"_comments": {
"plugins": {
"DotNetTest": "Runs dotnet test with Coverlet for one or more test projects (project/projects).",
"NpmJestTest": "Alternative for npm/Jest repos: workspaceRoot, testScript, coverageDirectory.",
"QualityGate": "Reads shared context metrics; set coverageThreshold > 0 to enforce minimum line coverage.",
"CoverageBadges": "Writes SVG badges from shared context metrics into badgesDir."
}
"paths": {
"readmePath": "..\\..\\..\\README.md",
"testResultsDir": "..\\..\\..\\test-results"
}
}

View File

@ -3,12 +3,14 @@
<#
.SYNOPSIS
Helpers to resolve engine semver and relative paths from plugin configuration.
Generic engine helpers: path resolution and the shared context facts API.
.DESCRIPTION
Used by New-EngineContext and version plugins:
- DotNetReleaseVersion plugin -> projectFiles (.csproj <Version>)
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
Engine-owned state stays on the context object (version, tag, skipPublishPlugins, ).
Plugin-published values go under $context.facts[namespace][name] via Set/Get/Test-EngineFact.
During migration, -LegacyProperty on Set dual-writes flat properties; Get falls back to them.
Version plugins declare providesVersion = $true; New-EngineContext discovers the single enabled one.
#>
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
@ -18,6 +20,16 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
}
}
$script:EngineStateAllowlist = @(
'version'
'tag'
'skipPublishPlugins'
'releaseDir'
'artifactsDirectory'
'deployMode'
'orchestrator'
)
function Resolve-RelativePaths {
param(
[Parameter(Mandatory = $true)]
@ -51,175 +63,270 @@ function Resolve-RelativePaths {
return @($resolved)
}
function Get-CsprojPropertyValue {
function Initialize-EngineFactsBag {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
[psobject]$Context
)
# SDK-style .csproj files can have multiple PropertyGroup nodes.
# Use the first group that defines the requested property.
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if (-not ($Context.PSObject.Properties.Name -contains 'facts') -or $null -eq $Context.facts) {
$Context | Add-Member -NotePropertyName facts -NotePropertyValue ([ordered]@{}) -Force
}
}
if ($propNode) {
return $propNode.$PropertyName
function Assert-EngineFactNamespace {
param(
[Parameter(Mandatory = $true)]
[string]$Namespace
)
if ($Namespace -eq 'engine') {
throw "Namespace 'engine' is reserved; use Set-EngineState / Get-EngineState for engine-owned fields."
}
if ($Namespace -cnotmatch '^[a-z][a-z0-9]*$') {
throw "Invalid facts namespace '$Namespace'. Use lowercase alphanumeric starting with a letter (e.g. 'test', 'dotnet')."
}
}
function Assert-EngineFactName {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
if ($Name -cnotmatch '^[a-zA-Z][a-zA-Z0-9_]*$') {
throw "Invalid fact name '$Name'. Use letters, digits, underscore; must start with a letter."
}
}
function Test-EngineFactValuePresent {
param(
[AllowNull()]
$Value
)
if ($null -eq $Value) {
return $false
}
if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) {
return $false
}
return $true
}
function Set-EngineFact {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Namespace,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[AllowNull()]
$Value,
[ValidateSet('Error', 'Replace', 'Keep')]
[string]$Overwrite = 'Error',
[Parameter(Mandatory = $false)]
[string]$LegacyProperty
)
Assert-EngineFactNamespace -Namespace $Namespace
Assert-EngineFactName -Name $Name
Initialize-EngineFactsBag -Context $Context
if (-not $Context.facts.Contains($Namespace)) {
$Context.facts[$Namespace] = [ordered]@{}
}
$bag = $Context.facts[$Namespace]
$exists = $bag.Contains($Name)
if ($exists) {
if ($Overwrite -eq 'Keep') {
if (-not [string]::IsNullOrWhiteSpace($LegacyProperty) -and -not ($Context.PSObject.Properties.Name -contains $LegacyProperty)) {
$Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $bag[$Name] -Force
}
return
}
if ($Overwrite -eq 'Error') {
throw "Fact ${Namespace}.${Name} already set (use -Overwrite Replace or Keep)."
}
}
$bag[$Name] = $Value
if (-not [string]::IsNullOrWhiteSpace($LegacyProperty)) {
$Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $Value -Force
}
}
function Get-EngineFact {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Namespace,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Default,
[switch]$Required,
[Parameter(Mandatory = $false)]
[string[]]$LegacyProperty
)
Assert-EngineFactNamespace -Namespace $Namespace
Assert-EngineFactName -Name $Name
Initialize-EngineFactsBag -Context $Context
if ($Context.facts.Contains($Namespace) -and $Context.facts[$Namespace].Contains($Name)) {
$value = $Context.facts[$Namespace][$Name]
if (Test-EngineFactValuePresent -Value $value) {
return $value
}
}
foreach ($propertyName in @($LegacyProperty)) {
if ([string]::IsNullOrWhiteSpace($propertyName)) {
continue
}
if ($Context.PSObject.Properties.Name -contains $propertyName) {
$legacyValue = $Context.$propertyName
if (Test-EngineFactValuePresent -Value $legacyValue) {
return $legacyValue
}
}
}
if ($Required) {
throw "Required fact ${Namespace}.${Name} is missing."
}
if ($PSBoundParameters.ContainsKey('Default')) {
return $Default
}
return $null
}
function Get-CsprojVersions {
function Test-EngineFact {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Namespace,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
[string[]]$LegacyProperty
)
Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..."
$projectVersions = @{}
$value = Get-EngineFact -Context $Context -Namespace $Namespace -Name $Name -LegacyProperty $LegacyProperty
return (Test-EngineFactValuePresent -Value $value)
}
foreach ($projectPath in $ProjectFiles) {
if (-not (Test-Path $projectPath -PathType Leaf)) {
Write-Error "Project file not found at: $projectPath"
exit 1
function Set-EngineState {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[AllowNull()]
$Value
)
if ($script:EngineStateAllowlist -notcontains $Name) {
throw "Engine state key '$Name' is not allowlisted. Use Set-EngineFact for plugin outputs, or extend the engine allowlist for documented engine fields."
}
$Context | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force
}
function Add-EnginePublishCompletion {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Publisher
)
if ([string]::IsNullOrWhiteSpace($Publisher)) {
throw "Publisher name is required."
}
$completedBy = @(Get-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Default @())
if ($completedBy -notcontains $Publisher) {
$completedBy += $Publisher
}
Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Value $completedBy -Overwrite Replace
Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completed' -Value $true -Overwrite Replace -LegacyProperty 'publishCompleted'
}
function Get-EngineState {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Default,
[switch]$Required
)
if ($script:EngineStateAllowlist -notcontains $Name) {
throw "Engine state key '$Name' is not allowlisted."
}
if ($Context.PSObject.Properties.Name -contains $Name) {
$value = $Context.$Name
if (Test-EngineFactValuePresent -Value $value) {
return $value
}
if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") {
Write-Error "Configured project file is not a .csproj file: $projectPath"
exit 1
}
[xml]$csproj = Get-Content $projectPath
$version = Get-CsprojPropertyValue -Csproj $csproj -PropertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projectPath"
exit 1
}
$projectVersions[$projectPath] = $version
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version"
}
return $projectVersions
if ($Required) {
throw "Required engine state '$Name' is missing."
}
if ($PSBoundParameters.ContainsKey('Default')) {
return $Default
}
return $null
}
function Resolve-DotNetReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir)
if ($projectFiles.Count -eq 0) {
Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with <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
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

@ -16,6 +16,36 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
}
}
function Test-IsEngineRuntimeModuleName {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName
)
# Engine runtime under modules/ only — never dual-homed under plugins/.
$engineNames = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@(
'ChangelogSupport',
'ExternalCommandSupport',
'GitTools',
'Logging',
'ScriptConfig',
'TestRunner',
'EngineContext',
'PluginSupport',
'ReleaseSupport',
'TestSupport',
'DeployConfig',
'EngineContextSupport',
'OrchestratorSupport',
'PluginPathSupport'
),
[System.StringComparer]::OrdinalIgnoreCase
)
return $engineNames.Contains($ModuleName)
}
function Import-PluginDependency {
param(
[Parameter(Mandatory = $true)]
@ -31,13 +61,29 @@ function Import-PluginDependency {
$modulesDir = Get-RepoUtilsModulesDirectory
$engineModuleDir = $PSScriptRoot
$modulePath = Join-Path $modulesDir "$ModuleName.psm1"
if (-not (Test-Path $modulePath -PathType Leaf)) {
$modulePath = Join-Path $engineModuleDir "$ModuleName.psm1"
$srcDir = Get-RepoUtilsSrcDirectory
$pluginsRoot = Join-Path $srcDir 'plugins'
$candidatePaths = [System.Collections.Generic.List[string]]::new()
if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) {
# Engine runtime: modules/ only (no plugins/ fallback).
$candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1"))
$candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1"))
$extensionsDir = Join-Path $modulesDir 'Extensions'
$candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1"))
}
else {
# Plugin helpers: plugins/ only (no modules/ legacy shadow).
foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) {
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1"))
}
}
if (Test-Path $modulePath -PathType Leaf) {
Import-Module $modulePath -Force -Global -ErrorAction Stop
foreach ($modulePath in $candidatePaths) {
if (Test-Path -LiteralPath $modulePath -PathType Leaf) {
Import-Module $modulePath -Force -Global -ErrorAction Stop
break
}
}
if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) {
@ -117,17 +163,277 @@ function Test-PluginAllowedOnBranch {
return $allowedBranches -contains $CurrentBranch
}
function Test-IsPublishPlugin {
function Get-PluginMetadataObject {
param(
[Parameter(Mandatory = $true)]
$Plugin
$Plugin,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory
)
$modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $modulePath -PathType Leaf)) {
return $null
}
try {
$moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop
$metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue
if (-not $metadataCommand) {
return $null
}
return & $metadataCommand
}
catch {
return $null
}
}
function Test-PluginCompatible {
<#
.SYNOPSIS
Applies an optional compatibility policy supplied by an extension.
#>
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
$extensionTest = Get-Command Test-ExtensionPluginCompatibility -ErrorAction SilentlyContinue
if ($extensionTest) {
return & $extensionTest @PSBoundParameters
}
return $true
}
function Test-PluginMutatesRemote {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $false)]
[string]$EngineDirectory
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
return $false
}
return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name)
if ([string]::IsNullOrWhiteSpace($EngineDirectory)) {
if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) {
$EngineDirectory = [string]$Plugin.context.scriptDir
}
elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) {
$EngineDirectory = [string]$Plugin.scriptDir
}
}
if ([string]::IsNullOrWhiteSpace($EngineDirectory)) {
return $false
}
$modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $modulePath -PathType Leaf)) {
return $false
}
try {
$moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop
$metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue
if (-not $metadataCommand) {
return $false
}
$metadata = & $metadataCommand
if ($null -eq $metadata) {
return $false
}
if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') {
return [bool]$metadata.mutatesRemote
}
}
catch {
return $false
}
return $false
}
function Get-SecretEnvironmentValue {
<#
.SYNOPSIS
Reads a secret value from an environment variable by logical name.
.DESCRIPTION
Plugins never store secret material in scriptSettings.json. Settings hold a
logical name (e.g. "GitHub", "NuGet"); the process environment variable with
that same name must be set before the engine runs.
.PARAMETER Name
Logical secret name also the environment variable name to read.
.OUTPUTS
System.String. The environment variable value, or $null when unset.
.EXAMPLE
$token = Get-SecretEnvironmentValue -Name 'GitHub'
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
return [Environment]::GetEnvironmentVariable($Name)
}
function Resolve-PluginSecretName {
<#
.SYNOPSIS
Resolves a logical secret name from a plugin's scriptSettings entry.
.DESCRIPTION
Reads a string property such as githubSecret / nugetSecret / npmSecret /
containerRegistrySecret from the plugin settings object. Returns $null when
the property is missing or blank.
.PARAMETER PluginSettings
Plugin settings object from scriptSettings.json (the enabled plugin entry).
.PARAMETER PropertyName
Settings property that holds the logical secret name (e.g. 'githubSecret').
.OUTPUTS
System.String. Trimmed logical secret name, or $null.
.EXAMPLE
$name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret'
$key = Get-SecretEnvironmentValue -Name $name
#>
param(
[Parameter(Mandatory = $true)]
$PluginSettings,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$value = [string]$PluginSettings.$PropertyName
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
return $null
}
function Get-RegistryCredentialsFromRuntime {
<#
.SYNOPSIS
Loads container-registry username/password from a logical secret name.
.DESCRIPTION
Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login
and image-pull secret creation never pass the password itself as a parameter.
.PARAMETER SecretName
Logical secret name (environment variable name), not a password or token.
.PARAMETER SharedSettings
Optional engine shared context (reserved for callers that thread context).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
.EXAMPLE
$creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry'
# $creds.User / $creds.Password
#>
# SecretName is a logical env-var name from scriptSettings, not a password value.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSAvoidUsingPlainTextForPassword',
'SecretName',
Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.'
)]
param(
[Parameter(Mandatory = $true)]
[string]$SecretName,
[Parameter(Mandatory = $false)]
[psobject]$SharedSettings
)
$raw = Get-SecretEnvironmentValue -Name $SecretName
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$SecretName' is not set."
}
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
}
catch {
throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
}
$parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$SecretName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
function Resolve-EngineDirectoryFromSharedSettings {
param(
[Parameter(Mandatory = $true)]
$SharedSettings
)
if ($SharedSettings.PSObject.Properties.Name -contains 'engineScriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.engineScriptDir)) {
return [string]$SharedSettings.engineScriptDir
}
return [string]$SharedSettings.scriptDir
}
function Test-PluginSkipsRemoteMutation {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings
)
$engineDirectory = Resolve-EngineDirectoryFromSharedSettings -SharedSettings $SharedSettings
if (-not (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory)) {
return $false
}
return ($Plugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $Plugin.dryRun -and [bool]$Plugin.dryRun)
}
function Test-IsPublishPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $false)]
[string]$EngineDirectory
)
return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory
}
function Get-PluginSettingValue {
@ -263,12 +569,31 @@ function Resolve-PluginModulePath {
$srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent
$pluginsRoot = Join-Path $srcDir "plugins"
$pluginFileName = "{0}.psm1" -f $Plugin.name
$candidatePaths = @(
(Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "Platform") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "DotNet") $pluginFileName),
(Join-Path (Join-Path $pluginsRoot "Npm") $pluginFileName)
)
$candidatePaths = [System.Collections.Generic.List[string]]::new()
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
$preferredGroups = @('Platform', 'DotNet', 'Npm')
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
foreach ($extensionPath in Get-ExtensionPluginModulePaths -PluginsRoot $pluginsRoot -PluginFileName $pluginFileName) {
$candidatePaths.Add($extensionPath)
}
}
foreach ($group in $preferredGroups[1..($preferredGroups.Count - 1)]) {
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) $pluginFileName))
}
$reservedPluginDirs = @($preferredGroups + @('Shared'))
if (Test-Path -LiteralPath $pluginsRoot -PathType Container) {
Get-ChildItem -LiteralPath $pluginsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notin $reservedPluginDirs } |
Sort-Object Name |
ForEach-Object {
$candidatePaths.Add((Join-Path $_.FullName $pluginFileName))
}
}
foreach ($candidatePath in $candidatePaths) {
if (Test-Path $candidatePath -PathType Leaf) {
@ -360,11 +685,34 @@ function Invoke-ConfiguredPlugin {
return $true
}
$metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory
if ($null -ne $metadata -and ($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) {
$versionAlreadySet = $false
if (Get-Command Get-EngineState -ErrorAction SilentlyContinue) {
$existingVersion = Get-EngineState -Context $SharedSettings -Name 'version' -ErrorAction SilentlyContinue
$versionAlreadySet = -not [string]::IsNullOrWhiteSpace([string]$existingVersion)
}
elseif (($SharedSettings.PSObject.Properties.Name -contains 'version') -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.version)) {
$versionAlreadySet = $true
}
if ($versionAlreadySet) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (version already resolved during New-EngineContext)."
return $true
}
# Test engine (and other hosts) may not resolve version in New-EngineContext; run the plugin now.
}
if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)."
return $true
}
if (-not (Test-PluginCompatible -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
return $true
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
@ -383,4 +731,4 @@ function Invoke-ConfiguredPlugin {
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -24,13 +24,101 @@ if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -n
}
}
if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) {
if (-not (Get-Command Resolve-RelativePaths -ErrorAction SilentlyContinue) -or -not (Get-Command Set-EngineState -ErrorAction SilentlyContinue)) {
$engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1"
if (Test-Path $engineContextModulePath -PathType Leaf) {
Import-Module $engineContextModulePath -Force
}
}
function Get-EnabledVersionPlugins {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$versionPlugins = @()
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace([string]$plugin.name)) {
continue
}
if (-not $plugin.enabled) {
continue
}
$metadata = Get-PluginMetadataObject -Plugin $plugin -EngineDirectory $ScriptDir
if ($null -eq $metadata) {
continue
}
if (($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) {
$versionPlugins += $plugin
}
}
return @($versionPlugins)
}
function Invoke-VersionPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory
)
$modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $modulePath -PathType Leaf)) {
throw "Version plugin '$($Plugin.name)' module not found at: $modulePath"
}
$moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $Context
& $invokeCommand -Settings $pluginSettings
}
function Resolve-EngineContextVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$versionPlugins = @(Get-EnabledVersionPlugins -Plugins $Plugins -ScriptDir $ScriptDir)
if ($versionPlugins.Count -eq 0) {
throw "Configure exactly one enabled release version plugin (declares providesVersion in Get-PluginMetadata), e.g. DotNetReleaseVersion (projectFiles), NpmReleaseVersion (packageJsonPath), or FileReleaseVersion (versionFilePath)."
}
if ($versionPlugins.Count -gt 1) {
$names = ($versionPlugins | ForEach-Object { [string]$_.name }) -join ', '
throw "Configure only one enabled release version plugin. Found: $names."
}
$versionPlugin = $versionPlugins[0]
Invoke-VersionPlugin -Plugin $versionPlugin -Context $Context -EngineDirectory $ScriptDir
$version = Get-EngineState -Context $Context -Name 'version'
if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version)) {
throw "Version plugin '$($versionPlugin.name)' did not set a version on the engine context."
}
return [string]$versionPlugin.name
}
function Assert-WorkingTreeClean {
$gitStatus = Get-GitStatusShort
if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) {
@ -48,21 +136,20 @@ function Assert-WorkingTreeClean {
function Initialize-ReleaseStageContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$RemainingPlugins,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$ArtifactsDirectory,
[Parameter(Mandatory = $true)]
[string]$Version
[string]$ArtifactsDirectory
)
if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) {
$SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $SharedSettings -Name 'releaseDir' -Value $ArtifactsDirectory
}
else {
$SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force
}
}
}
@ -78,13 +165,13 @@ function New-EngineContext {
[string]$SrcDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
[psobject]$Settings,
[Parameter(Mandatory = $false)]
[psobject]$ExtensionData
)
$resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
$version = $resolvedVersion.version
$versionSource = $resolvedVersion.source
$releaseRelative = '..\..\..\release'
$releaseRelative = '..\..\..\releases'
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative))
$currentBranch = Get-CurrentBranch
@ -111,28 +198,58 @@ function New-EngineContext {
$releaseBranches = @('main')
}
$isReleaseBranch = $releaseBranches -contains $currentBranch
$isNonReleaseBranch = -not $isReleaseBranch
$isNonReleaseBranch = -not ($releaseBranches -contains $currentBranch)
Assert-WorkingTreeClean
$tag = "v$version"
Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
return [pscustomobject]@{
$context = [pscustomobject]@{
scriptDir = $ScriptDir
srcDir = $SrcDir
utilsDir = $SrcDir
currentBranch = $currentBranch
version = $version
tag = $tag
artifactsDirectory = $artifactsDirectory
isReleaseBranch = $isReleaseBranch
isNonReleaseBranch = $isNonReleaseBranch
releaseBranches = $releaseBranches
publishCompleted = $false
skipPublishPlugins = $false
facts = [ordered]@{}
}
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required)
$tag = "v$version"
Set-EngineState -Context $context -Name 'tag' -Value $tag
Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
$dryRunPlugins = @(
$Plugins |
Where-Object {
$_.enabled -and
($_.PSObject.Properties.Name -contains 'dryRun') -and
$null -ne $_.dryRun -and
[bool]$_.dryRun -and
(Test-PluginMutatesRemote -Plugin $_ -EngineDirectory $ScriptDir)
} |
ForEach-Object { [string]$_.name }
)
if ($dryRunPlugins.Count -gt 0) {
Write-Log -Level "INFO" -Message " Plugin dryRun (validate only): $($dryRunPlugins -join ', ')"
}
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) {
$expandParams = @{
Context = $context
ScriptDir = $ScriptDir
Settings = $Settings
}
if ($PSBoundParameters.ContainsKey('ExtensionData')) {
$expandParams['ExtensionData'] = $ExtensionData
}
return & $expandContext @expandParams
}
return $context
}
function Get-PreferredReleaseBranch {

View File

@ -10,6 +10,13 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
}
}
if (-not (Get-Command Initialize-EngineFactsBag -ErrorAction SilentlyContinue)) {
$engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1"
if (Test-Path $engineContextModulePath -PathType Leaf) {
Import-Module $engineContextModulePath -Force
}
}
function New-EngineContext {
param(
[Parameter(Mandatory = $true)]
@ -19,20 +26,34 @@ function New-EngineContext {
[string]$SrcDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
[psobject]$Settings,
[Parameter(Mandatory = $false)]
[psobject]$ExtensionData
)
$badgesDir = $null
if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) {
$badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir)))
}
return [pscustomobject]@{
$context = [pscustomobject]@{
scriptDir = $ScriptDir
srcDir = $SrcDir
utilsDir = $SrcDir
badgesDir = $badgesDir
facts = [ordered]@{}
}
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) {
$expandParams = @{
Context = $context
ScriptDir = $ScriptDir
Settings = $Settings
}
if ($PSBoundParameters.ContainsKey('ExtensionData')) {
$expandParams['ExtensionData'] = $ExtensionData
}
return & $expandContext @expandParams
}
return $context
}
Export-ModuleMember -Function New-EngineContext

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()
}
& git @Arguments
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null
}
else {
& git @Arguments
}
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
Write-Error "$ErrorMessage (exit code: $exitCode)"

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
@ -96,8 +118,8 @@ function Invoke-TestsWithCoverage {
$ErrorActionPreference = "Stop"
# Normalize to a non-empty list of absolute .csproj paths.
$resolvedProjectFiles = [System.Collections.Generic.List[string]]::new()
# Normalize to a non-empty list of absolute working directories (folder containing the test project).
$resolvedProjectDirs = [System.Collections.Generic.List[string]]::new()
foreach ($raw in $TestProjectPath) {
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$full = [System.IO.Path]::GetFullPath($raw.Trim())
@ -107,46 +129,22 @@ function Invoke-TestsWithCoverage {
Error = "Test project not found at: $raw"
}
}
$item = Get-Item -LiteralPath $full
if ($item.PSIsContainer) {
$csprojFiles = @(Get-ChildItem -Path $item.FullName -Filter '*.csproj' -File | Sort-Object Name)
if ($csprojFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No .csproj file found in test project directory: $($item.FullName)"
}
}
foreach ($csproj in $csprojFiles) {
if ($resolvedProjectFiles -notcontains $csproj.FullName) {
[void]$resolvedProjectFiles.Add($csproj.FullName)
}
}
continue
}
if ([System.IO.Path]::GetExtension($item.FullName) -ne '.csproj') {
return [PSCustomObject]@{
Success = $false
Error = "Test project path is not a .csproj file or directory: $full"
}
}
if ($resolvedProjectFiles -notcontains $item.FullName) {
[void]$resolvedProjectFiles.Add($item.FullName)
$dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName }
if ($resolvedProjectDirs -notcontains $dir) {
[void]$resolvedProjectDirs.Add($dir)
}
}
if ($resolvedProjectFiles.Count -eq 0) {
if ($resolvedProjectDirs.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No valid test project paths were provided."
}
}
$firstProjectDir = [System.IO.Path]::GetDirectoryName($resolvedProjectFiles[0])
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $firstProjectDir "TestResults"
$ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults"
}
else {
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
@ -160,53 +158,39 @@ function Invoke-TestsWithCoverage {
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
foreach ($projectFile in $resolvedProjectFiles) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $projectFile"
foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
}
$verbosity = if ($Silent) { 'quiet' } else { 'normal' }
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
foreach ($projectFile in $resolvedProjectFiles) {
$buildArgs = @('build', $projectFile, '-v', $verbosity)
if ($Silent) {
$null = & dotnet @buildArgs 2>&1
}
else {
& dotnet @buildArgs
}
Import-ExternalCommandSupportInternal
if ($Silent) {
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput
}
else {
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default
}
if ($LASTEXITCODE -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Build failed for '$projectFile' with exit code $LASTEXITCODE"
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode"
}
}
}
}
foreach ($projectFile in $resolvedProjectFiles) {
$dotnetArgs = @(
'test'
$projectFile
'--no-build'
'--collect:XPlat Code Coverage'
'--results-directory', $ResultsDir
'--verbosity', $verbosity
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
}
else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$projectFile' with exit code $testExitCode"
}
finally {
Pop-Location
}
}
@ -370,11 +354,12 @@ function Invoke-NpmJestTestsWithCoverage {
Push-Location $workspaceFull
try {
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
Import-ExternalCommandSupportInternal
if ($Silent) {
$null = & npm @npmArgs 2>&1
$null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput
}
else {
& npm @npmArgs
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default
}
if ($LASTEXITCODE -ne 0) {
@ -428,4 +413,272 @@ function Invoke-NpmJestTestsWithCoverage {
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage
function Get-NpmCoverageFromSummaryFile {
param(
[Parameter(Mandatory = $true)]
[string]$SummaryPath,
[switch]$Silent
)
if (-not (Test-Path -LiteralPath $SummaryPath -PathType Leaf)) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary not found at: $SummaryPath"
}
}
$summaryJson = Get-Content -LiteralPath $SummaryPath -Raw -Encoding UTF8 | ConvertFrom-Json
$total = $summaryJson.total
if ($null -eq $total) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary is missing 'total' metrics in: $SummaryPath"
}
}
return [PSCustomObject]@{
Success = $true
LineRate = [math]::Round([double]$total.lines.pct, 1)
BranchRate = [math]::Round([double]$total.branches.pct, 1)
MethodRate = [math]::Round([double]$total.functions.pct, 1)
TotalMethods = [int]$total.functions.total
CoveredMethods = [int]$total.functions.covered
CoverageFormat = 'npm'
CoverageSummaryFile = $SummaryPath
}
}
function Get-DotNetCoverageFromResultsDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$ResultsDirectory,
[switch]$Silent
)
$coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found under: $ResultsDirectory"
}
}
$linesCoveredTotal = 0L
$linesValidTotal = 0L
$branchesCoveredTotal = 0L
$branchesValidTotal = 0L
$totalMethods = 0
$coveredMethods = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$root = $coverageXml.coverage
$lcAttr = $root.'lines-covered'
$lvAttr = $root.'lines-valid'
if ($null -ne $lcAttr -and $null -ne $lvAttr -and [long]$lvAttr -gt 0) {
$linesCoveredTotal += [long]$lcAttr
$linesValidTotal += [long]$lvAttr
}
$bcAttr = $root.'branches-covered'
$bvAttr = $root.'branches-valid'
if ($null -ne $bcAttr -and $null -ne $bvAttr -and [long]$bvAttr -gt 0) {
$branchesCoveredTotal += [long]$bcAttr
$branchesValidTotal += [long]$bvAttr
}
foreach ($package in @($root.packages.package)) {
foreach ($class in @($package.classes.class)) {
$methodNodes = $class.methods
if ($null -eq $methodNodes) { continue }
foreach ($method in @($methodNodes.method)) {
if ($null -eq $method) { continue }
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
}
}
}
}
}
if ($linesValidTotal -gt 0) {
$lineRate = [math]::Round(($linesCoveredTotal / $linesValidTotal) * 100, 1)
}
else {
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'line-rate'
$n++
}
$lineRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
if ($branchesValidTotal -gt 0) {
$branchRate = [math]::Round(($branchesCoveredTotal / $branchesValidTotal) * 100, 1)
}
else {
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'branch-rate'
$n++
}
$branchRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
$coveragePaths = @($coverageFiles | ForEach-Object { $_.FullName })
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = ($coveragePaths -join ';')
CoverageFiles = $coveragePaths
ResultsDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory)
CoverageFormat = 'dotnet'
}
}
function Get-CoverageFromResultsDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$ResultsDirectory,
[ValidateSet('auto', 'dotnet', 'npm')]
[string]$Format = 'auto',
[switch]$Silent
)
$resolvedDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory)
if (-not (Test-Path -LiteralPath $resolvedDirectory -PathType Container)) {
return [PSCustomObject]@{
Success = $false
Error = "Results directory not found: $resolvedDirectory"
}
}
$hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
$effectiveFormat = $Format
if ($effectiveFormat -eq 'auto') {
if ($hasDotNet -and -not $hasNpm) {
$effectiveFormat = 'dotnet'
}
elseif ($hasNpm -and -not $hasDotNet) {
$effectiveFormat = 'npm'
}
elseif ($hasDotNet) {
$effectiveFormat = 'dotnet'
}
elseif ($hasNpm) {
$effectiveFormat = 'npm'
}
else {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found under: $resolvedDirectory"
}
}
}
if ($effectiveFormat -eq 'dotnet') {
return Get-DotNetCoverageFromResultsDirectory -ResultsDirectory $resolvedDirectory -Silent:$Silent
}
if ($effectiveFormat -eq 'npm') {
$npmResult = Get-NpmCoverageFromSummaryFile -SummaryPath $jestSummary -Silent:$Silent
if (-not $npmResult.Success) {
return $npmResult
}
$npmResult | Add-Member -NotePropertyName ResultsDirectory -NotePropertyValue $resolvedDirectory -Force
return $npmResult
}
return [PSCustomObject]@{
Success = $false
Error = "Unsupported coverage format '$Format'."
}
}
function Publish-CoverageMetricsToSharedContext {
param(
[Parameter(Mandatory = $true)]
$SharedSettings,
[Parameter(Mandatory = $true)]
$TestResult
)
if (-not (Get-Command Set-EngineFact -ErrorAction SilentlyContinue)) {
$engineContextPath = Join-Path $PSScriptRoot 'Engine' 'EngineContext.psm1'
if (Test-Path -LiteralPath $engineContextPath -PathType Leaf) {
Import-Module $engineContextPath -Force -Global
}
}
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResult' -Value $TestResult -Overwrite Replace -LegacyProperty 'testResult'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageLineRate' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'coverageLineRate'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'qualityLineCoverage' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'qualityLineCoverage'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageBranchRate' -Value $TestResult.BranchRate -Overwrite Replace -LegacyProperty 'coverageBranchRate'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageMethodRate' -Value $TestResult.MethodRate -Overwrite Replace -LegacyProperty 'coverageMethodRate'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageTotalMethods' -Value $TestResult.TotalMethods -Overwrite Replace -LegacyProperty 'coverageTotalMethods'
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoveredMethods' -Value $TestResult.CoveredMethods -Overwrite Replace -LegacyProperty 'coverageCoveredMethods'
if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') {
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageFormat' -Value $TestResult.CoverageFormat -Overwrite Replace -LegacyProperty 'coverageFormat'
}
if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) {
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResultsDirectory' -Value $TestResult.ResultsDirectory -Overwrite Replace -LegacyProperty 'testResultsDirectory'
}
if ($TestResult.CoverageFiles) {
Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoberturaPaths' -Value @($TestResult.CoverageFiles) -Overwrite Replace -LegacyProperty 'coverageCoberturaPaths'
}
return
}
$SharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $TestResult -Force
$SharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $TestResult.LineRate -Force
$SharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $TestResult.LineRate -Force
$SharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $TestResult.BranchRate -Force
$SharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $TestResult.MethodRate -Force
$SharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $TestResult.TotalMethods -Force
$SharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $TestResult.CoveredMethods -Force
if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') {
$SharedSettings | Add-Member -NotePropertyName coverageFormat -NotePropertyValue $TestResult.CoverageFormat -Force
}
if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) {
$SharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $TestResult.ResultsDirectory -Force
}
if ($TestResult.CoverageFiles) {
$SharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($TestResult.CoverageFiles) -Force
}
}
Export-ModuleMember -Function `
Invoke-TestsWithCoverage, `
Invoke-NpmJestTestsWithCoverage, `
Get-NpmCoverageFromSummaryFile, `
Get-DotNetCoverageFromResultsDirectory, `
Get-CoverageFromResultsDirectory, `
Publish-CoverageMetricsToSharedContext

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)
}
elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$archiveInputs = @($sharedSettings.packageFile.FullName)
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$archiveInputs += $sharedSettings.symbolsPackageFile.FullName
else {
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
if ($null -ne $packageFile) {
$archiveInputs = @($packageFile.FullName)
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
if ($null -ne $symbolsPackageFile) {
$archiveInputs += $symbolsPackageFile.FullName
}
}
}
@ -79,16 +85,18 @@ function Invoke-Plugin {
Write-Log -Level "OK" -Message " Release archive ready: $zipPath"
$releaseAssetPaths = @($zipPath)
if ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$releaseAssetPaths += $sharedSettings.packageFile.FullName
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
if ($null -ne $packageFile) {
$releaseAssetPaths += $packageFile.FullName
}
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
if ($null -ne $symbolsPackageFile) {
$releaseAssetPaths += $symbolsPackageFile.FullName
}
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchivePath -NotePropertyValue $zipPath -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
Export-ModuleMember -Function Invoke-Plugin

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

@ -29,22 +29,36 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$nugetApiKeyEnvVar = $pluginSettings.nugetApiKey
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
Assert-Command dotnet
if (-not $packageFile) {
throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet."
}
if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) {
throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json."
if ($dryRun) {
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {
"https://api.nuget.org/v3/index.json"
}
else {
$pluginSettings.source
}
Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource"
return
}
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
if ([string]::IsNullOrWhiteSpace($nugetApiKey)) {
throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun."
if ([string]::IsNullOrWhiteSpace($nugetSecret)) {
throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
}
$nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
if ([string]::IsNullOrWhiteSpace($nugetKey)) {
throw "NuGet API key is not set. Set environment variable '$nugetSecret'."
}
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {
@ -55,17 +69,22 @@ function Invoke-Plugin {
}
Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..."
dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate
if ($LASTEXITCODE -ne 0) {
throw "Failed to push the package to NuGet feed."
}
Write-Log -Level "OK" -Message " NuGet push completed."
$sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'DotNetNuGet'
}
Export-ModuleMember -Function Invoke-Plugin
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $true }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

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

@ -3,11 +3,13 @@
<#
.SYNOPSIS
Loads release version into shared context.
Loads release version from an SDK-style .csproj into shared context.
.DESCRIPTION
Dedicated version-loading plugin. It reads .csproj version via
EngineContext helpers and writes Version into the shared runtime context.
Dedicated version-loading plugin. Reads <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)) {
@ -18,6 +20,56 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
}
}
function Get-CsprojPropertyValueInternal {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
# SDK-style .csproj files can have multiple PropertyGroup nodes.
# Use the first group that defines the requested property.
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
}
return $null
}
function Get-CsprojVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectPath
)
if (-not (Test-Path $ProjectPath -PathType Leaf)) {
throw "DotNetReleaseVersion: project file not found at '$ProjectPath'."
}
if ([System.IO.Path]::GetExtension($ProjectPath) -ne ".csproj") {
throw "DotNetReleaseVersion: configured project file is not a .csproj file: '$ProjectPath'."
}
[xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
}
return [string]$version
}
function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true }
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
@ -25,17 +77,20 @@ function Invoke-Plugin {
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-DotNetReleaseVersion"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
$shared = $Settings.context
$resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir
$projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir)
if ($projectFiles.Count -eq 0) {
throw "DotNetReleaseVersion plugin requires 'projectFiles' (first .csproj with <Version>) in scriptSettings.json."
}
$shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force
$shared | Add-Member -NotePropertyName projectFiles -NotePropertyValue $projectFiles -Force
Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $($shared.version)"
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $version"
}
Export-ModuleMember -Function Invoke-Plugin
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

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)) {
@ -37,75 +38,15 @@ function Invoke-Plugin {
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.scriptDir
function Resolve-PluginPath {
param(
[Parameter(Mandatory = $true)]
[string]$ConfiguredPath,
[Parameter(Mandatory = $true)]
[string]$PrimaryBasePath,
[Parameter(Mandatory = $false)]
[string[]]$FallbackBasePaths
)
$trimmedPath = $ConfiguredPath.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedPath)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($trimmedPath)) {
return [System.IO.Path]::GetFullPath($trimmedPath)
}
$candidateBases = [System.Collections.Generic.List[string]]::new()
[void]$candidateBases.Add($PrimaryBasePath)
foreach ($fallbackBase in @($FallbackBasePaths)) {
if (-not [string]::IsNullOrWhiteSpace($fallbackBase) -and $candidateBases -notcontains $fallbackBase) {
[void]$candidateBases.Add($fallbackBase)
}
}
foreach ($candidateBase in $candidateBases) {
$candidatePath = [System.IO.Path]::GetFullPath((Join-Path $candidateBase $trimmedPath))
if (Test-Path $candidatePath) {
return $candidatePath
}
}
# Preserve backward-compatible behavior when no fallback path exists.
return [System.IO.Path]::GetFullPath((Join-Path $PrimaryBasePath $trimmedPath))
}
$fallbackBasePaths = @()
if ($sharedSettings.PSObject.Properties.Name -contains 'srcDir' -and $sharedSettings.srcDir) {
$fallbackBasePaths += [string]$sharedSettings.srcDir
try {
$repoRoot = Split-Path -Parent ([string]$sharedSettings.srcDir)
if (-not [string]::IsNullOrWhiteSpace($repoRoot)) {
$fallbackBasePaths += $repoRoot
}
}
catch {
# Ignore invalid fallback roots and keep primary behavior.
}
}
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
if ($pluginSettings.PSObject.Properties.Name -contains 'projects' -and $pluginSettings.projects) {
foreach ($rel in @($pluginSettings.projects)) {
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
$resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$rel) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths
if ($resolvedPath) {
$testProjectPaths.Add($resolvedPath)
}
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) {
$resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$pluginSettings.project) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths
if ($resolvedPath) {
$testProjectPaths.Add($resolvedPath)
}
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project)))
}
if ($testProjectPaths.Count -eq 0) {
throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json."
@ -128,6 +69,7 @@ function Invoke-Plugin {
}
if ($testResultsDir) {
$invokeTestParams.ResultsDirectory = $testResultsDir
$invokeTestParams.KeepResults = $true
}
$testResult = Invoke-TestsWithCoverage @invokeTestParams
@ -136,19 +78,8 @@ function Invoke-Plugin {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force
$sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force
$sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force
if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) {
$sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force
}
if ($testResult.CoverageFiles) {
$sharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($testResult.CoverageFiles) -Force
}
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext"
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"

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'..."
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
npm publish --access $access --userconfig $tempNpmRcPath
}
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."
}
@ -105,7 +127,8 @@ registry=$registry
}
Write-Log -Level "OK" -Message " npm publish completed."
$shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
}
finally {
if (Test-Path $tempNpmRcPath -PathType Leaf) {
@ -115,4 +138,8 @@ registry=$registry
}
}
Export-ModuleMember -Function Invoke-Plugin
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $true }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

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
)
$Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $Shared -Name 'skipPublishPlugins' -Value $true
}
else {
$Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force
}
if ($When -eq 'fail') {
Write-Log -Level "ERROR" -Message "ReleasePublishGuard: $Reason"
exit 1
throw "ReleasePublishGuard: $Reason"
}
Write-Log -Level "WARN" -Message " Publish suppressed: $Reason"
@ -67,6 +72,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "PluginSupport" -RequiredCommand "Get-PluginBranches"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
@ -82,7 +88,7 @@ function Invoke-Plugin {
throw "ReleasePublishGuard: whenRequirementsNotMet must be 'skip' or 'fail'."
}
$shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $false -Force
Set-EngineState -Context $shared -Name 'skipPublishPlugins' -Value $false
Write-Log -Level "STEP" -Message "Release publish guard..."
@ -138,7 +144,7 @@ function Invoke-Plugin {
return
}
$shared | Add-Member -NotePropertyName tag -NotePropertyValue $tag -Force
Set-EngineState -Context $shared -Name 'tag' -Value $tag
}
$ensureRemote = $true

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
}
if (-not $dryRun) {
Copy-Item -Path $file.FullName -Destination $backupPath -Force
}
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
}
Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)"
Write-Log -Level "OK" -Message "Preserved $($existingPreservedFiles.Count) existing $preserveFileName file(s)"
}
else {
Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders"
}
$releaseDeployPreserveFiles = @(Get-ReleaseDeployPreserveFiles -TargetDirectory $targetDirectory)
if ($releaseDeployPreserveFiles.Count -gt 0) {
foreach ($file in $releaseDeployPreserveFiles) {
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
}
Write-Log -Level "OK" -Message "Preserved $($releaseDeployPreserveFiles.Count) release deploy file(s)"
}
$preservedRelativePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($preservedFile in $preservedFiles) {
[void]$preservedRelativePaths.Add($preservedFile.RelativePath)
}
if ($dryRun) {
Write-LogStep "Dry run summary"
Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files"
Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName and release deploy files"
Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')"
if ($omittedRelativeDirectories.Count -gt 0) {
Write-Log -Level "INFO" -Message "Would omit (delete dest + skip copy): $($omittedRelativeDirectories -join ', ')"
}
Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory"
if ($preservedFiles.Count -gt 0) {
$preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", "
@ -275,9 +345,12 @@ try {
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
$_.Name -ne $preserveFileName -and
-not $isInSkippedDirectory
-not $preservedRelativePaths.Contains($relativePath) -and
(-not $isInSkippedDirectory -or $isInOmittedDirectory)
}
foreach ($file in $filesToRemove) {
@ -289,7 +362,10 @@ try {
foreach ($directory in $directoriesToRemove) {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName)
if (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) {
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
if ((Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) -and
-not $isInOmittedDirectory) {
continue
}
@ -305,8 +381,10 @@ try {
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
-not $isInSkippedDirectory
-not $isInSkippedDirectory -and -not $isInOmittedDirectory
}
foreach ($sourceFile in $sourceFilesToCopy) {
@ -326,6 +404,9 @@ try {
Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory"
}
}
foreach ($omittedDirectory in $omittedRelativeDirectories) {
Write-Log -Level "INFO" -Message "Omitted (not shipped): $omittedDirectory"
}
Write-Log -Level "OK" -Message "Source files copied"
if ($preservedFiles.Count -gt 0) {
@ -342,7 +423,7 @@ try {
Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force
}
Write-Log -Level "OK" -Message "$preserveFileName files restored"
Write-Log -Level "OK" -Message "Preserved files restored ($($preservedFiles.Count))"
}
Write-Log -Level "OK" -Message "========================================"

View File

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