(build): bump to 1.6.8 and update nuget dependencies
This commit is contained in:
parent
4a6fdd1f3f
commit
b5f31faf9e
118
.editorconfig
Normal file
118
.editorconfig
Normal file
@ -0,0 +1,118 @@
|
||||
# EditorConfig: https://editorconfig.org
|
||||
# Shared code style for VS Code, Cursor, Visual Studio, Rider
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
|
||||
# =========================
|
||||
# C#
|
||||
# =========================
|
||||
[*.cs]
|
||||
indent_size = 2
|
||||
|
||||
# K&R braces (opening brace on same line)
|
||||
csharp_new_line_before_open_brace = none
|
||||
|
||||
# keep else/catch/finally on new line
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
|
||||
# indentation
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
|
||||
# spacing
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
|
||||
# Fluent / chained method calls (ReSharper compatibility)
|
||||
resharper_csharp_continuous_indent_multiplier = 1
|
||||
resharper_continuous_indent_multiplier = 1
|
||||
resharper_csharp_align_multiline_calls_chain = false
|
||||
resharper_align_multiline_calls_chain = false
|
||||
resharper_csharp_outdent_dots = false
|
||||
resharper_outdent_dots = false
|
||||
|
||||
# Two blank lines between last using and namespace
|
||||
resharper_csharp_blank_lines_between_using_and_namespace = 2
|
||||
resharper_blank_lines_between_using_and_namespace = 2
|
||||
|
||||
# Usings: System first; blank line between System and other groups (Roslyn)
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
resharper_sort_usings_system_first = true
|
||||
|
||||
# Remove unnecessary using directives (dotnet format style --diagnostics IDE0005)
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
# File-scoped namespace declarations (IDE0160)
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_diagnostic.IDE0160.severity = warning
|
||||
|
||||
# Omit braces on single-line if/else/for/foreach/while/using (IDE0011)
|
||||
csharp_prefer_braces = when_multiline:none
|
||||
dotnet_diagnostic.IDE0011.severity = none
|
||||
|
||||
# Prefer 'var' over explicit type (IDE0008)
|
||||
csharp_style_var_for_built_in_types = true:none
|
||||
csharp_style_var_when_type_is_apparent = true:none
|
||||
csharp_style_var_elsewhere = true:none
|
||||
dotnet_diagnostic.IDE0008.severity = none
|
||||
|
||||
# Prefer if-return over ternary for conditional returns (IDE0046) — multi-line guards stay readable
|
||||
dotnet_style_prefer_conditional_expression_over_return = false:none
|
||||
dotnet_diagnostic.IDE0046.severity = none
|
||||
|
||||
# Expression-bodied members: prefer => over { return ...; } (IDE0022–IDE0027)
|
||||
csharp_style_expression_bodied_methods = true:warning
|
||||
csharp_style_expression_bodied_properties = true:warning
|
||||
csharp_style_expression_bodied_accessors = true:warning
|
||||
csharp_style_expression_bodied_indexers = true:warning
|
||||
csharp_style_expression_bodied_operators = true:warning
|
||||
csharp_style_expression_bodied_lambdas = true:none
|
||||
csharp_style_expression_bodied_local_functions = false:none
|
||||
csharp_style_expression_bodied_constructors = false:none
|
||||
dotnet_diagnostic.IDE0022.severity = warning
|
||||
dotnet_diagnostic.IDE0023.severity = warning
|
||||
dotnet_diagnostic.IDE0024.severity = warning
|
||||
dotnet_diagnostic.IDE0025.severity = warning
|
||||
dotnet_diagnostic.IDE0026.severity = warning
|
||||
dotnet_diagnostic.IDE0027.severity = warning
|
||||
|
||||
# => on signature line; expression indented on next line (no blank line after =>)
|
||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning
|
||||
dotnet_diagnostic.IDE2006.severity = warning
|
||||
resharper_csharp_wrap_before_arrow_with_expressions = false
|
||||
resharper_wrap_before_arrow_with_expressions = false
|
||||
|
||||
|
||||
# =========================
|
||||
# TypeScript / JavaScript
|
||||
# =========================
|
||||
[*.{ts,tsx,js,jsx}]
|
||||
indent_size = 2
|
||||
|
||||
|
||||
# =========================
|
||||
# JSON / YAML
|
||||
# =========================
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
|
||||
# =========================
|
||||
# Markdown
|
||||
# =========================
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -262,4 +262,8 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
#Custom
|
||||
![Uu]tils/**
|
||||
![Uu]tils/**
|
||||
|
||||
# Generated test reports (RepoUtils)
|
||||
test-results/
|
||||
utils/tests/results/
|
||||
|
||||
@ -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
|
||||
|
||||
165
CONTRIBUTING.md
165
CONTRIBUTING.md
@ -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
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# MaksIT.Core Library Documentation
|
||||
|
||||
  
|
||||

|
||||

|
||||

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