diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d3e7b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,118 @@ +# EditorConfig: https://editorconfig.org +# Shared code style for VS Code, Cursor, Visual Studio, Rider + +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space + +# ========================= +# C# +# ========================= +[*.cs] +indent_size = 2 + +# K&R braces (opening brace on same line) +csharp_new_line_before_open_brace = none + +# keep else/catch/finally on new line +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# indentation +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# spacing +csharp_space_after_cast = false +csharp_space_after_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false + +# Fluent / chained method calls (ReSharper compatibility) +resharper_csharp_continuous_indent_multiplier = 1 +resharper_continuous_indent_multiplier = 1 +resharper_csharp_align_multiline_calls_chain = false +resharper_align_multiline_calls_chain = false +resharper_csharp_outdent_dots = false +resharper_outdent_dots = false + +# Two blank lines between last using and namespace +resharper_csharp_blank_lines_between_using_and_namespace = 2 +resharper_blank_lines_between_using_and_namespace = 2 + +# Usings: System first; blank line between System and other groups (Roslyn) +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +resharper_sort_usings_system_first = true + +# Remove unnecessary using directives (dotnet format style --diagnostics IDE0005) +dotnet_diagnostic.IDE0005.severity = warning + +# File-scoped namespace declarations (IDE0160) +csharp_style_namespace_declarations = file_scoped:warning +dotnet_diagnostic.IDE0160.severity = warning + +# Omit braces on single-line if/else/for/foreach/while/using (IDE0011) +csharp_prefer_braces = when_multiline:none +dotnet_diagnostic.IDE0011.severity = none + +# Prefer 'var' over explicit type (IDE0008) +csharp_style_var_for_built_in_types = true:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = true:none +dotnet_diagnostic.IDE0008.severity = none + +# Prefer if-return over ternary for conditional returns (IDE0046) — multi-line guards stay readable +dotnet_style_prefer_conditional_expression_over_return = false:none +dotnet_diagnostic.IDE0046.severity = none + +# Expression-bodied members: prefer => over { return ...; } (IDE0022–IDE0027) +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_lambdas = true:none +csharp_style_expression_bodied_local_functions = false:none +csharp_style_expression_bodied_constructors = false:none +dotnet_diagnostic.IDE0022.severity = warning +dotnet_diagnostic.IDE0023.severity = warning +dotnet_diagnostic.IDE0024.severity = warning +dotnet_diagnostic.IDE0025.severity = warning +dotnet_diagnostic.IDE0026.severity = warning +dotnet_diagnostic.IDE0027.severity = warning + +# => on signature line; expression indented on next line (no blank line after =>) +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning +dotnet_diagnostic.IDE2006.severity = warning +resharper_csharp_wrap_before_arrow_with_expressions = false +resharper_wrap_before_arrow_with_expressions = false + + +# ========================= +# TypeScript / JavaScript +# ========================= +[*.{ts,tsx,js,jsx}] +indent_size = 2 + + +# ========================= +# JSON / YAML +# ========================= +[*.{json,yml,yaml}] +indent_size = 2 + + +# ========================= +# Markdown +# ========================= +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 5f09bf8..402b144 100644 --- a/.gitignore +++ b/.gitignore @@ -263,4 +263,10 @@ __pycache__/ **/*docker-compose/LetsEncryptServer/acme -**/*docker-compose/LetsEncryptServer/cache \ No newline at end of file +**/*docker-compose/LetsEncryptServer/cache +#Custom +![Uu]tils/** + +# Generated test reports (RepoUtils) +test-results/ +utils/tests/results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 902ed5f..9bc3fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.1] - 2026-06-28 ### Changed -- Migrated repository automation from `utils/` to `src/` (maksit-repoutils layout). Configure release and test flows in `src/engines/*/scriptSettings.json`. +- Restored repository automation under `utils/` (aligned with maksit-core and maksit-repoutils). Configure release and test flows in `utils/engines/*/scriptSettings.json`. +- Updated dependencies to Dapr `1.18.4`, `MaksIT.Core` `1.6.8`, and `MaksIT.Results` `2.0.3`. -## v2.0.0 - 2026-02-22 +## [2.0.0] - 2026-02-22 ### Added - Dedicated test project (`MaksIT.Dapr.Tests`) with coverage for publisher and state-store service behavior. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f29fb0a..5fed35d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,48 +72,53 @@ This project follows [Semantic Versioning](https://semver.org/): ## Utility Scripts -Automation lives under `src/` (synced from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils) via `Update-RepoUtils`). Product-specific settings are in each engine’s `scriptSettings.json`. +Automation lives under `utils/` (synced from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils) via `Update-RepoUtils`). Product-specific settings are in each engine’s `scriptSettings.json`. ### Run tests and coverage badges ```powershell -pwsh -File .\src\engines\test\Invoke-TestEngine.ps1 +pwsh -File .\utils\engines\test\Invoke-TestEngine.ps1 ``` -Configuration: `src/engines/test/scriptSettings.json` +Or double-click `utils\Invoke-TestEngine.bat`. + +Configuration: `utils/engines/test/scriptSettings.json` ### Release package Builds, tests, packs, and publishes to NuGet and GitHub release flows. ```powershell -pwsh -File .\src\engines\release\Invoke-ReleasePackage.ps1 +pwsh -File .\utils\engines\release\Invoke-ReleasePackage.ps1 ``` +Or run `utils\Invoke-ReleasePackage-Single.bat` (or `-HA.bat` when Helm deploy is wired). + Prerequisites: - Docker Desktop (for Linux test validation) - GitHub CLI (`gh`) -- environment variable `NUGET_MAKS_IT` -- environment variable `GITHUB_MAKS_IT_COM` +- Environment variables **`NuGet`** and **`GitHub`** (logical names in `scriptSettings.json`) -Configuration: `src/engines/release/scriptSettings.json` +Configuration: `utils/engines/release/scriptSettings.json` ### Update repo utilities -Refreshes `src/` from maksit-repoutils while preserving local `scriptSettings.json` files. +Refreshes `utils/` from maksit-repoutils while preserving local `scriptSettings.json` files. ```powershell -pwsh -File .\src\tools\Update-RepoUtils\Update-RepoUtils.ps1 +pwsh -File .\utils\tools\Update-RepoUtils\Update-RepoUtils.ps1 ``` +Or double-click `utils\Update-RepoUtils.bat`. + ### Force amend tagged commit Amends the latest tagged commit and force-pushes updated branch and tag. ```powershell -pwsh -File .\src\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -pwsh -File .\src\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun +pwsh -File .\utils\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 +pwsh -File .\utils\tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun ``` Warning: this rewrites git history. diff --git a/README.md b/README.md index 973edc9..82a608f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # MaksIT.Dapr -![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-62.1%25-green) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-50%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-60%25-green) This repository hosts the `maksit-dapr` project, which utilizes [Dapr](https://dapr.io/) (Distributed Application Runtime) to facilitate building and managing microservices with ease. The project focuses on implementing a robust, scalable solution leveraging Dapr's building blocks and abstractions. diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg deleted file mode 100644 index ee69f16..0000000 --- a/assets/badges/coverage-branches.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Branch Coverage: 50% - - - - - - - - - - - - - - - Branch Coverage - - 50% - - diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg deleted file mode 100644 index 64fd339..0000000 --- a/assets/badges/coverage-lines.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Line Coverage: 62.1% - - - - - - - - - - - - - - - Line Coverage - - 62.1% - - diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg deleted file mode 100644 index 2ae5b4f..0000000 --- a/assets/badges/coverage-methods.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Method Coverage: 60% - - - - - - - - - - - - - - - Method Coverage - - 60% - - diff --git a/src/Invoke-ReleasePackage.bat b/src/Invoke-ReleasePackage.bat deleted file mode 100644 index 85d776a..0000000 --- a/src/Invoke-ReleasePackage.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* -pause diff --git a/src/Invoke-TestEngine.bat b/src/Invoke-TestEngine.bat deleted file mode 100644 index 0cfd13f..0000000 --- a/src/Invoke-TestEngine.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* -pause diff --git a/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj index 5623534..3921c40 100644 --- a/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj +++ b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/MaksIT.Dapr/MaksIT.Dapr.csproj b/src/MaksIT.Dapr/MaksIT.Dapr.csproj index c53c63e..b77270e 100644 --- a/src/MaksIT.Dapr/MaksIT.Dapr.csproj +++ b/src/MaksIT.Dapr/MaksIT.Dapr.csproj @@ -7,7 +7,7 @@ MaksIT.Dapr - 2.0.0 + 2.0.1 Maksym Sadovnychyy MAKS-IT MaksIT.Dapr @@ -21,11 +21,11 @@ - - - - - + + + + + diff --git a/src/modules/Engine/EngineContext.psm1 b/src/modules/Engine/EngineContext.psm1 deleted file mode 100644 index 9d397d1..0000000 --- a/src/modules/Engine/EngineContext.psm1 +++ /dev/null @@ -1,225 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - Helpers to resolve engine semver and relative paths from plugin configuration. - -.DESCRIPTION - Used by New-EngineContext and version plugins: - - DotNetReleaseVersion plugin -> projectFiles (.csproj ) - - NpmReleaseVersion plugin -> packageJsonPath (package.json version) -#> - -if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { - $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" - if (Test-Path $loggingModulePath -PathType Leaf) { - Import-Module $loggingModulePath -Force - } -} - -function Resolve-RelativePaths { - param( - [Parameter(Mandatory = $true)] - [object]$Value, - - [Parameter(Mandatory = $true)] - [string]$BasePath - ) - - if ($null -eq $Value) { - return @() - } - - $rawPaths = @() - if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { - $rawPaths += $Value - } - else { - $rawPaths += $Value - } - - $resolved = @() - foreach ($p in $rawPaths) { - if ([string]::IsNullOrWhiteSpace([string]$p)) { - continue - } - - $resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p))) - } - - return @($resolved) -} - -function Get-CsprojPropertyValue { - param( - [Parameter(Mandatory = $true)] - [xml]$Csproj, - - [Parameter(Mandatory = $true)] - [string]$PropertyName - ) - - # SDK-style .csproj files can have multiple PropertyGroup nodes. - # Use the first group that defines the requested property. - $propNode = $Csproj.Project.PropertyGroup | - Where-Object { $_.$PropertyName } | - Select-Object -First 1 - - if ($propNode) { - return $propNode.$PropertyName - } - - return $null -} - -function Get-CsprojVersions { - param( - [Parameter(Mandatory = $true)] - [string[]]$ProjectFiles - ) - - Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..." - $projectVersions = @{} - - foreach ($projectPath in $ProjectFiles) { - if (-not (Test-Path $projectPath -PathType Leaf)) { - Write-Error "Project file not found at: $projectPath" - exit 1 - } - - if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") { - Write-Error "Configured project file is not a .csproj file: $projectPath" - exit 1 - } - - [xml]$csproj = Get-Content $projectPath - $version = Get-CsprojPropertyValue -Csproj $csproj -PropertyName "Version" - - if (-not $version) { - Write-Error "Version not found in $projectPath" - exit 1 - } - - $projectVersions[$projectPath] = $version - Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version" - } - - return $projectVersions -} - -function Resolve-DotNetReleaseVersion { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$ScriptDir - ) - - $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1) - if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { - Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles." - exit 1 - } - - $releaseVersionSettings = $releaseVersionPlugin[0] - $projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir) - - if ($projectFiles.Count -eq 0) { - Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with )." - exit 1 - } - - $projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles - $version = $projectVersions[$projectFiles[0]] - - return [pscustomobject]@{ - version = $version - source = 'DotNetReleaseVersion' - } -} - -function Resolve-NpmReleaseVersion { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$ScriptDir - ) - - $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1) - if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { - Write-Error "Configure an NpmReleaseVersion plugin in scriptSettings.json with packageJsonPath." - exit 1 - } - - $releaseVersionSettings = $releaseVersionPlugin[0] - $packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir) - - if ($packageJsonPaths.Count -eq 0) { - Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath." - exit 1 - } - - $packageJsonPath = $packageJsonPaths[0] - if (-not (Test-Path $packageJsonPath -PathType Leaf)) { - Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath" - exit 1 - } - - Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..." - $json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json - $version = [string]$json.version - if ([string]::IsNullOrWhiteSpace($version)) { - Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'." - exit 1 - } - - if ($version -notmatch '^\d+\.\d+\.\d+') { - Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver." - exit 1 - } - - Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version" - - return [pscustomobject]@{ - version = $version - source = 'NpmReleaseVersion' - } -} - -function Resolve-ReleaseVersion { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$ScriptDir - ) - - $dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false }) - $npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false }) - - if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) { - Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both." - exit 1 - } - - if ($dotnetPlugin.Count -gt 0) { - return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir - } - - if ($npmPlugin.Count -gt 0) { - return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir - } - - Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json." - exit 1 -} - -Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion - - - diff --git a/src/modules/Engine/PluginSupport.psm1 b/src/modules/Engine/PluginSupport.psm1 deleted file mode 100644 index 30371ea..0000000 --- a/src/modules/Engine/PluginSupport.psm1 +++ /dev/null @@ -1,386 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -function Get-RepoUtilsSrcDirectory { - return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -} - -function Get-RepoUtilsModulesDirectory { - return Split-Path $PSScriptRoot -Parent -} - -if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { - $loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1" - if (Test-Path $loggingModulePath -PathType Leaf) { - Import-Module $loggingModulePath -Force - } -} - -function Import-PluginDependency { - param( - [Parameter(Mandatory = $true)] - [string]$ModuleName, - - [Parameter(Mandatory = $true)] - [string]$RequiredCommand - ) - - if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) { - return - } - - $modulesDir = Get-RepoUtilsModulesDirectory - $engineModuleDir = $PSScriptRoot - $modulePath = Join-Path $modulesDir "$ModuleName.psm1" - if (-not (Test-Path $modulePath -PathType Leaf)) { - $modulePath = Join-Path $engineModuleDir "$ModuleName.psm1" - } - - if (Test-Path $modulePath -PathType Leaf) { - Import-Module $modulePath -Force -Global -ErrorAction Stop - } - - if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { - throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'." - } -} - -function Get-ConfiguredPlugins { - param( - [Parameter(Mandatory = $true)] - [psobject]$Settings - ) - - if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) { - return @() - } - - if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) { - return @($Settings.plugins) - } - - return @($Settings.plugins) -} - -function Get-PluginStageLabel { - param( - [Parameter(Mandatory = $true)] - $Plugin - ) - - if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) { - return 'release' - } - - return [string]$Plugin.stageLabel -} - -function Get-PluginBranches { - param( - [Parameter(Mandatory = $true)] - $Plugin - ) - - if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) { - return @() - } - - if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) { - return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - } - - if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) { - return @() - } - - return @([string]$Plugin.branches) -} - -function Test-PluginAllowedOnBranch { - param( - [Parameter(Mandatory = $true)] - $Plugin, - - [Parameter(Mandatory = $true)] - [string]$CurrentBranch - ) - - $allowedBranches = Get-PluginBranches -Plugin $Plugin - if ($allowedBranches.Count -eq 0) { - return $true - } - - if ($allowedBranches -contains '*') { - return $true - } - - return $allowedBranches -contains $CurrentBranch -} - -function Test-IsPublishPlugin { - param( - [Parameter(Mandatory = $true)] - $Plugin - ) - - if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { - return $false - } - - return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name) -} - -function Get-PluginSettingValue { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$PropertyName - ) - - foreach ($plugin in $Plugins) { - if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { - continue - } - - if (-not $plugin.PSObject.Properties[$PropertyName]) { - continue - } - - $value = $plugin.$PropertyName - if ($null -eq $value) { - continue - } - - if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { - continue - } - - return $value - } - - return $null -} - -function Get-PluginPathListSetting { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$PropertyName, - - [Parameter(Mandatory = $true)] - [string]$BasePath - ) - - $rawPaths = @() - $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName - - if ($null -eq $value) { - return @() - } - - if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { - $rawPaths += $value - } - else { - $rawPaths += $value - } - - $resolvedPaths = @() - foreach ($path in $rawPaths) { - if ([string]::IsNullOrWhiteSpace([string]$path)) { - continue - } - - $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path))) - } - - return @($resolvedPaths) -} - -function Get-PluginPathSetting { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$PropertyName, - - [Parameter(Mandatory = $true)] - [string]$BasePath - ) - - $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName - if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { - return $null - } - - return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value))) -} - -function Get-ArchiveNamePattern { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$CurrentBranch - ) - - foreach ($plugin in $Plugins) { - if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { - continue - } - - if (-not $plugin.enabled) { - continue - } - - if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) { - continue - } - - if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) { - return [string]$plugin.zipNamePattern - } - } - - return "release-{version}.zip" -} - -function Resolve-PluginModulePath { - param( - [Parameter(Mandatory = $true)] - $Plugin, - - [Parameter(Mandatory = $true)] - [string]$EngineDirectory - ) - - $srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent - $pluginsRoot = Join-Path $srcDir "plugins" - $pluginFileName = "{0}.psm1" -f $Plugin.name - $candidatePaths = @( - (Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "Platform") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "DotNet") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "Npm") $pluginFileName) - ) - - foreach ($candidatePath in $candidatePaths) { - if (Test-Path $candidatePath -PathType Leaf) { - return $candidatePath - } - } - - return $candidatePaths[0] -} - -function Test-PluginRunnable { - param( - [Parameter(Mandatory = $true)] - $Plugin, - - [Parameter(Mandatory = $true)] - [psobject]$SharedSettings, - - [Parameter(Mandatory = $true)] - [string]$EngineDirectory, - - [Parameter(Mandatory = $false)] - [bool]$WriteLogs = $true - ) - - if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) { - if ($WriteLogs) { - Write-Log -Level "WARN" -Message "Skipping plugin entry with no name." - } - return $false - } - - if (-not $Plugin.enabled) { - if ($WriteLogs) { - Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)." - } - return $false - } - - $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory - if (-not (Test-Path $pluginModulePath -PathType Leaf)) { - if ($WriteLogs) { - Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath" - } - return $false - } - - return $true -} - -function New-PluginInvocationSettings { - param( - [Parameter(Mandatory = $true)] - $Plugin, - - [Parameter(Mandatory = $true)] - [psobject]$SharedSettings - ) - - $properties = @{} - foreach ($property in $Plugin.PSObject.Properties) { - $properties[$property.Name] = $property.Value - } - - $properties['context'] = $SharedSettings - return [pscustomobject]$properties -} - -function Invoke-ConfiguredPlugin { - param( - [Parameter(Mandatory = $true)] - $Plugin, - - [Parameter(Mandatory = $true)] - [psobject]$SharedSettings, - - [Parameter(Mandatory = $true)] - [string]$EngineDirectory, - - [Parameter(Mandatory = $false)] - [bool]$ContinueOnError = $false - ) - - if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { - if ($Plugin.enabled) { - return $false - } - - return $true - } - - if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) { - Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)." - return $true - } - - $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory - Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." - - try { - $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop - $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop - $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings - - & $invokeCommand -Settings $pluginSettings - Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed." - return $true - } - catch { - Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)" - return $false - } -} - -Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/src/modules/Engine/ReleaseSupport.psm1 b/src/modules/Engine/ReleaseSupport.psm1 deleted file mode 100644 index ffda8d7..0000000 --- a/src/modules/Engine/ReleaseSupport.psm1 +++ /dev/null @@ -1,151 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -$modulesDir = Split-Path $PSScriptRoot -Parent - -if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { - $loggingModulePath = Join-Path $modulesDir "Logging.psm1" - if (Test-Path $loggingModulePath -PathType Leaf) { - Import-Module $loggingModulePath -Force - } -} - -if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) { - $gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1" - if (Test-Path $gitToolsModulePath -PathType Leaf) { - Import-Module $gitToolsModulePath -Force - } -} - -if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) { - $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force - } -} - -if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) { - $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" - if (Test-Path $engineContextModulePath -PathType Leaf) { - Import-Module $engineContextModulePath -Force - } -} - -function Assert-WorkingTreeClean { - $gitStatus = Get-GitStatusShort - if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) { - Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)." - foreach ($line in @([string]$gitStatus -split "`r?`n")) { - if (-not [string]::IsNullOrWhiteSpace($line)) { - Write-Log -Level "WARN" -Message " $line" - } - } - return - } - - Write-Log -Level "OK" -Message " Working directory is clean." -} - -function Initialize-ReleaseStageContext { - param( - [Parameter(Mandatory = $true)] - [object[]]$RemainingPlugins, - - [Parameter(Mandatory = $true)] - [psobject]$SharedSettings, - - [Parameter(Mandatory = $true)] - [string]$ArtifactsDirectory, - - [Parameter(Mandatory = $true)] - [string]$Version - ) - - if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) { - $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force - } -} - -function New-EngineContext { - param( - [Parameter(Mandatory = $true)] - [object[]]$Plugins, - - [Parameter(Mandatory = $true)] - [string]$ScriptDir, - - [Parameter(Mandatory = $true)] - [string]$SrcDir, - - [Parameter(Mandatory = $false)] - [psobject]$Settings - ) - - $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir - $version = $resolvedVersion.version - $versionSource = $resolvedVersion.source - $releaseRelative = '..\..\..\release' - $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative)) - - $currentBranch = Get-CurrentBranch - - $releaseBranches = @() - foreach ($p in $Plugins) { - if (-not $p.enabled) { continue } - if ([string]$p.name -ne 'ReleasePublishGuard') { continue } - foreach ($b in (Get-PluginBranches -Plugin $p)) { - $releaseBranches += $b - } - } - $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) - if ($releaseBranches.Count -eq 0) { - foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) { - if (-not $p.enabled) { continue } - foreach ($b in (Get-PluginBranches -Plugin $p)) { - $releaseBranches += $b - } - } - $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) - } - if ($releaseBranches.Count -eq 0) { - $releaseBranches = @('main') - } - - $isReleaseBranch = $releaseBranches -contains $currentBranch - $isNonReleaseBranch = -not $isReleaseBranch - - Assert-WorkingTreeClean - - $tag = "v$version" - Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." - - return [pscustomobject]@{ - scriptDir = $ScriptDir - srcDir = $SrcDir - utilsDir = $SrcDir - currentBranch = $currentBranch - version = $version - tag = $tag - artifactsDirectory = $artifactsDirectory - isReleaseBranch = $isReleaseBranch - isNonReleaseBranch = $isNonReleaseBranch - releaseBranches = $releaseBranches - publishCompleted = $false - skipPublishPlugins = $false - } -} - -function Get-PreferredReleaseBranch { - param( - [Parameter(Mandatory = $true)] - [psobject]$EngineContext - ) - - if ($EngineContext.releaseBranches.Count -gt 0) { - return $EngineContext.releaseBranches[0] - } - - return "main" -} - -Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch diff --git a/src/modules/Engine/TestSupport.psm1 b/src/modules/Engine/TestSupport.psm1 deleted file mode 100644 index a90d03d..0000000 --- a/src/modules/Engine/TestSupport.psm1 +++ /dev/null @@ -1,38 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -$modulesDir = Split-Path $PSScriptRoot -Parent - -if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { - $loggingModulePath = Join-Path $modulesDir "Logging.psm1" - if (Test-Path $loggingModulePath -PathType Leaf) { - Import-Module $loggingModulePath -Force - } -} - -function New-EngineContext { - param( - [Parameter(Mandatory = $true)] - [string]$ScriptDir, - - [Parameter(Mandatory = $true)] - [string]$SrcDir, - - [Parameter(Mandatory = $false)] - [psobject]$Settings - ) - - $badgesDir = $null - if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) { - $badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir))) - } - - return [pscustomobject]@{ - scriptDir = $ScriptDir - srcDir = $SrcDir - utilsDir = $SrcDir - badgesDir = $badgesDir - } -} - -Export-ModuleMember -Function New-EngineContext diff --git a/src/plugins/DotNet/DotNetDockerPush.psm1 b/src/plugins/DotNet/DotNetDockerPush.psm1 deleted file mode 100644 index 6e6855a..0000000 --- a/src/plugins/DotNet/DotNetDockerPush.psm1 +++ /dev/null @@ -1,245 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET Docker publish plugin — build and push container images for .NET apps. - -.DESCRIPTION - Logs in with credentials from a Base64-encoded username:password environment variable, - builds each configured image once, then tags and pushes: bare semver from DotNetReleaseVersion - (e.g. 3.3.4), v-prefixed alias (v3.3.4) when different, optional exact shared.tag if it differs, - and optional latest. - - Release image tags align with shared.version (same bare semver as Helm chart/OCI when used together); not from Chart.yaml. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - -function Set-EnvVersionValue { - param( - [Parameter(Mandatory = $true)] - [string]$FilePath, - - [Parameter(Mandatory = $true)] - [string]$Version - ) - - $content = Get-Content -LiteralPath $FilePath -Raw - if ($content -match '(?m)^\s*VITE_APP_VERSION\s*=') { - $content = $content -replace '(?m)^\s*VITE_APP_VERSION\s*=.*$', "VITE_APP_VERSION=$Version" - } - else { - $separator = if ($content -match "(\r?\n)$") { '' } else { [Environment]::NewLine } - $content = "$content${separator}VITE_APP_VERSION=$Version" - } - - Set-Content -LiteralPath $FilePath -Value $content -NoNewline -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - - $pluginSettings = $Settings - $shared = $Settings.context - - Assert-Command docker - - if ([string]::IsNullOrWhiteSpace($pluginSettings.registryUrl)) { - throw "DotNetDockerPush plugin requires 'registryUrl' (registry hostname, no scheme)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetDockerPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.projectName)) { - throw "DotNetDockerPush plugin requires 'projectName' (image path segment after registry)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.contextPath)) { - throw "DotNetDockerPush plugin requires 'contextPath' (Docker build context, relative to engines/release folder)." - } - - if (-not $pluginSettings.images -or @($pluginSettings.images).Count -eq 0) { - throw "DotNetDockerPush plugin requires a non-empty 'images' array with 'service' and 'dockerfile' per entry." - } - - $scriptDir = $shared.scriptDir - $contextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.contextPath))) - if (-not (Test-Path $contextPath -PathType Container)) { - throw "Docker context directory not found: $contextPath" - } - - $registryUrl = [string]$pluginSettings.registryUrl.TrimEnd('/') - $creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar) - - $bareVersion = $null - if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) { - $bareVersion = ([string]$shared.version).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($bareVersion) -and $shared.PSObject.Properties.Name -contains 'tag') { - $bareVersion = ([string]$shared.tag).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($bareVersion)) { - throw "DotNetDockerPush: could not derive version tag (need shared.version from DotNetReleaseVersion or shared.tag)." - } - - $imageTags = New-Object System.Collections.Generic.List[string] - function Add-ImageTag([System.Collections.Generic.List[string]]$List, [string]$Tag) { - if ([string]::IsNullOrWhiteSpace($Tag)) { return } - if (-not $List.Contains($Tag)) { [void]$List.Add($Tag) } - } - Add-ImageTag $imageTags $bareVersion - Add-ImageTag $imageTags "v$bareVersion" - if ($shared.PSObject.Properties.Name -contains 'tag') { - Add-ImageTag $imageTags ([string]$shared.tag).Trim() - } - $pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $true } - if ($pushLatest) { - Add-ImageTag $imageTags 'latest' - } - - Write-Log -Level "STEP" -Message "Docker login to $registryUrl..." - $loginResult = $creds.Password | docker login $registryUrl -u $creds.User --password-stdin 2>&1 - if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch 'Login Succeeded')) { - throw "Docker login failed for ${registryUrl}: $loginResult" - } - - try { - foreach ($img in @($pluginSettings.images)) { - if ($null -eq $img.service -or $null -eq $img.dockerfile) { - throw "Each images[] entry must define 'service' and 'dockerfile'." - } - - $service = [string]$img.service - $dockerfileRel = [string]$img.dockerfile - - $imgContextPath = $contextPath - if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) { - $imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath))) - if (-not (Test-Path $imgContextPath -PathType Container)) { - throw "Docker context directory not found for image '$service': $imgContextPath" - } - } - - $dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel)) - if (-not (Test-Path $dockerfilePath -PathType Leaf)) { - throw "Dockerfile not found: $dockerfilePath" - } - $baseName = "$registryUrl/$($pluginSettings.projectName)/$service" - - $versionEnvFiles = @() - if ($img.PSObject.Properties.Name -contains 'versionEnvFiles' -and $null -ne $img.versionEnvFiles) { - foreach ($relativeEnvFile in @($img.versionEnvFiles)) { - if ([string]::IsNullOrWhiteSpace([string]$relativeEnvFile)) { - continue - } - - $envFilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath ([string]$relativeEnvFile))) - if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) { - throw "Configured versionEnvFiles entry not found: $envFilePath" - } - - $backupPath = "$envFilePath.repoutils.bak" - Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force - $versionEnvFiles += [pscustomobject]@{ - FilePath = $envFilePath - BackupPath = $backupPath - } - } - } - - try { - foreach ($envFile in $versionEnvFiles) { - Write-Log -Level "INFO" -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)" - Set-EnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion - } - - $primaryRef = "${baseName}:$($imageTags[0])" - Write-Log -Level "STEP" -Message "Building $primaryRef ..." - docker build -t $primaryRef -f $dockerfilePath $imgContextPath - if ($LASTEXITCODE -ne 0) { - throw "Docker build failed for $primaryRef" - } - - Write-Log -Level "STEP" -Message "Pushing $primaryRef ..." - docker push $primaryRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $primaryRef" - } - - for ($ti = 1; $ti -lt $imageTags.Count; $ti++) { - $aliasRef = "${baseName}:$($imageTags[$ti])" - Write-Log -Level "STEP" -Message "Tagging and pushing $aliasRef ..." - docker tag $primaryRef $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker tag failed: $primaryRef -> $aliasRef" - } - docker push $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $aliasRef" - } - } - } - finally { - foreach ($envFile in $versionEnvFiles) { - if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) { - Move-Item -LiteralPath $envFile.BackupPath -Destination $envFile.FilePath -Force - } - } - foreach ($envFile in $versionEnvFiles) { - if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) { - Remove-Item -LiteralPath $envFile.BackupPath -Force -ErrorAction SilentlyContinue - } - } - } - } - } - finally { - docker logout $registryUrl 2>&1 | Out-Null - } - - Write-Log -Level "OK" -Message " Docker push completed." - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/src/plugins/DotNet/DotNetHelmPush.psm1 b/src/plugins/DotNet/DotNetHelmPush.psm1 deleted file mode 100644 index 44532f0..0000000 --- a/src/plugins/DotNet/DotNetHelmPush.psm1 +++ /dev/null @@ -1,181 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET Helm publish plugin — package and push charts versioned from DotNetReleaseVersion. - -.DESCRIPTION - The chart in the repo should keep placeholder version and appVersion (e.g. 0.0.0); this plugin - overwrites them with the bare semver from shared context (DotNetReleaseVersion / shared.version, - e.g. 3.3.4 — no leading v), falling back to stripping v/V from shared.tag if version is missing, - then runs helm package and helm push, then restores Chart.yaml. - - Optional pushLatest (default false when omitted): when true, after the versioned push, copies the chart - to a :latest tag in the same OCI repository using the oras CLI (https://oras.land). Requires oras on PATH. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - - $pluginSettings = $Settings - $shared = $Settings.context - - Assert-Command helm - - $pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $false } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.chartPath)) { - throw "DotNetHelmPush plugin requires 'chartPath' (chart directory, relative to engines/release folder)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.ociRepository)) { - throw "DotNetHelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetHelmPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." - } - - $scriptDir = $shared.ScriptDir - $chartDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.chartPath))) - $chartYaml = Join-Path $chartDir 'Chart.yaml' - - if (-not (Test-Path $chartYaml -PathType Leaf)) { - throw "Chart.yaml not found at: $chartYaml" - } - - $chartVersion = $null - if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) { - $chartVersion = ([string]$shared.version).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($chartVersion) -and $shared.PSObject.Properties.Name -contains 'tag') { - $chartVersion = ([string]$shared.tag).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($chartVersion)) { - throw "Could not derive chart version: need shared.version (DotNetReleaseVersion) or shared.tag (e.g. v3.3.4)." - } - - $creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar) - $ociRepository = [string]$pluginSettings.ociRepository.TrimEnd('/') - - $chartNameLine = Select-String -LiteralPath $chartYaml -Pattern '^\s*name:\s*(.+)\s*$' | Select-Object -First 1 - if (-not $chartNameLine -or $chartNameLine.Matches.Count -lt 1) { - throw "Could not read chart name from Chart.yaml." - } - $chartName = $chartNameLine.Matches[0].Groups[1].Value.Trim() - - $backupPath = "$chartYaml.bak" - Copy-Item -LiteralPath $chartYaml -Destination $backupPath -Force - - try { - $content = Get-Content -LiteralPath $chartYaml -Raw - $content = $content ` - -replace '(?m)^\s*version:\s*.*$', "version: $chartVersion" ` - -replace '(?m)^\s*appVersion:\s*.*$', "appVersion: `"$chartVersion`"" - Set-Content -LiteralPath $chartYaml -Value $content - - Write-Log -Level "STEP" -Message "Linting Helm chart at $chartDir ..." - helm lint $chartDir - if ($LASTEXITCODE -ne 0) { - throw "helm lint failed." - } - - $packageDest = $scriptDir - Write-Log -Level "STEP" -Message "Packaging Helm chart..." - $packageOutput = helm package $chartDir --destination $packageDest 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - throw "helm package failed. Output: $packageOutput" - } - - $chartPackage = Join-Path $packageDest "$chartName-$chartVersion.tgz" - if (-not (Test-Path -LiteralPath $chartPackage -PathType Leaf)) { - throw "Expected chart package not found: $chartPackage (helm output: $packageOutput)" - } - - Write-Log -Level "STEP" -Message "Pushing $chartPackage to $ociRepository ..." - helm push $chartPackage $ociRepository --username $creds.User --password $creds.Password - if ($LASTEXITCODE -ne 0) { - throw "helm push failed." - } - - if ($pushLatest) { - Assert-Command oras - if ($ociRepository -notmatch '^oci://([^/]+)') { - throw "Could not parse registry host from ociRepository: $ociRepository" - } - $registryHost = $Matches[1] - $baseRef = "$($ociRepository.TrimEnd('/'))/$chartName" - $srcRef = "${baseRef}:$chartVersion" - $dstRef = "${baseRef}:latest" - - Write-Log -Level "STEP" -Message "Tagging chart as latest (oras copy)..." - Write-Log -Level "INFO" -Message " $srcRef -> $dstRef" - - $loginOut = $creds.Password | & oras login $registryHost -u $creds.User --password-stdin 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "oras login failed for ${registryHost}: $loginOut" - } - - $copyOut = & oras copy $srcRef $dstRef 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "oras copy failed: $copyOut" - } - - & oras logout $registryHost 2>&1 | Out-Null - Write-Log -Level "OK" -Message " Chart latest tag pushed." - } - - Remove-Item -LiteralPath $chartPackage -Force -ErrorAction SilentlyContinue - Write-Log -Level "OK" -Message " Helm chart push completed." - } - finally { - if (Test-Path -LiteralPath $backupPath -PathType Leaf) { - Move-Item -LiteralPath $backupPath -Destination $chartYaml -Force - } - } - - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/src/plugins/DotNet/DotNetReleaseVersion.psm1 b/src/plugins/DotNet/DotNetReleaseVersion.psm1 deleted file mode 100644 index 66ac1b6..0000000 --- a/src/plugins/DotNet/DotNetReleaseVersion.psm1 +++ /dev/null @@ -1,41 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - Loads release version into shared context. - -.DESCRIPTION - Dedicated version-loading plugin. It reads .csproj version via - EngineContext helpers and writes Version into the shared runtime context. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-DotNetReleaseVersion" - - $shared = $Settings.context - $resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir - $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir) - - $shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force - $shared | Add-Member -NotePropertyName projectFiles -NotePropertyValue $projectFiles -Force - Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $($shared.version)" -} - -Export-ModuleMember -Function Invoke-Plugin - - diff --git a/src/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit.bat similarity index 100% rename from src/Force-AmendTaggedCommit.bat rename to utils/Force-AmendTaggedCommit.bat diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat new file mode 100644 index 0000000..2255f3c --- /dev/null +++ b/utils/Invoke-ReleasePackage.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat new file mode 100644 index 0000000..7953777 --- /dev/null +++ b/utils/Invoke-TestEngine.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +exit /b %ERRORLEVEL% diff --git a/src/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat similarity index 100% rename from src/Update-RepoUtils.bat rename to utils/Update-RepoUtils.bat diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 new file mode 100644 index 0000000..4ad9cbc --- /dev/null +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -0,0 +1,118 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven release engine entry script. + +.NOTES + Per-plugin dryRun on mutatesRemote plugins validates without remote mutation. + There is no engine-wide dryRun switch — set dryRun on each plugin as needed. +#> + +# Keep a plain param block so unbound launcher arguments remain in $args for +# optional Initialize-ReleaseExtension (advanced binding would reject them). +param() + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') +Import-EngineModules -Engine Release + +$releaseExtension = $null +if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) { + $releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList $args +} + +$settings = if ($null -ne $releaseExtension) { + $releaseExtension.Settings +} +else { + Get-ScriptSettings -ScriptDir $PSScriptRoot +} + +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +$releaseBanner = if ($null -ne $releaseExtension) { + $releaseExtension.StepBanner +} +else { + 'RELEASE ENGINE' +} + +Write-Log -Level 'STEP' -Message '==================================================' +Write-Log -Level 'STEP' -Message $releaseBanner +Write-Log -Level 'STEP' -Message '==================================================' + +$plugins = $configuredPlugins +$newContextParams = @{ + Plugins = $plugins + ScriptDir = $PSScriptRoot + SrcDir = $srcDir + Settings = $settings +} +if ($null -ne $releaseExtension) { + $newContextParams['ExtensionData'] = $releaseExtension.ContextData +} + +$engineContext = New-EngineContext @newContextParams + +Write-Log -Level 'OK' -Message 'All pre-flight checks passed!' +$sharedPluginSettings = $engineContext + +$releaseStageInitialized = $false +$releaseHadPluginFailures = $false + +if ($plugins.Count -eq 0) { + Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.' +} +else { + for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) { + $plugin = $plugins[$pluginIndex] + + if ((Test-IsPublishPlugin -Plugin $plugin -EngineDirectory $PSScriptRoot) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -WriteLogs:$false) { + Initialize-ReleaseStageContext -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory + $releaseStageInitialized = $true + } + } + + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false + if (-not $pluginSucceeded) { + $releaseHadPluginFailures = $true + break + } + } +} + +if (-not $releaseStageInitialized) { + $noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' } + Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).' +} + +Write-Log -Level 'OK' -Message '==================================================' +if ($releaseHadPluginFailures) { + Write-Log -Level 'ERROR' -Message 'RELEASE FAILED' +} +elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) { + Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)' +} +elseif ($engineContext.isNonReleaseBranch) { + Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE' +} +else { + Write-Log -Level 'OK' -Message 'RELEASE COMPLETE' +} +Write-Log -Level 'OK' -Message '==================================================' + +if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) { + $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext + Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements." +} + +if ($releaseHadPluginFailures) { + exit 1 +} diff --git a/utils/engines/release/custom/.gitkeep b/utils/engines/release/custom/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/engines/release/custom/.gitkeep @@ -0,0 +1 @@ + diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json new file mode 100644 index 0000000..4b17cf3 --- /dev/null +++ b/utils/engines/release/scriptSettings.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release Package Script Settings", + "description": "Invoke-ReleasePackage.ps1 plugin settings for maksit-dapr.", + "plugins": [ + { + "name": "DotNetReleaseVersion", + "stageLabel": "build", + "enabled": true, + "projectFiles": [ + "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" + ] + }, + { + "name": "DotNetTest", + "stageLabel": "test", + "enabled": true, + "project": "..\\..\\..\\src\\MaksIT.Dapr.Tests", + "resultsDir": "..\\..\\..\\testResults" + }, + { + "name": "QualityGate", + "stageLabel": "qualityGate", + "enabled": true, + "coverageThreshold": 0, + "failOnVulnerabilities": true, + "projectFiles": [ + "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" + ] + }, + { + "name": "DotNetPack", + "stageLabel": "build", + "enabled": true, + "projectFiles": [ + "..\\..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" + ], + "artifactsDir": "..\\..\\..\\releases" + }, + { + "name": "DotNetCreateArchive", + "stageLabel": "build", + "enabled": true, + "zipNamePattern": "maksit.dapr-{version}.zip" + }, + { + "name": "ReleasePublishGuard", + "stageLabel": "release", + "enabled": true, + "branches": [ + "main" + ], + "requireExactTagOnHead": true, + "tagVersionMustMatchDotNetRelease": true, + "whenRequirementsNotMet": "skip", + "requireCleanWorkingTree": false, + "ensureTagOnRemote": true, + "remoteName": "origin" + }, + { + "name": "GitHub", + "stageLabel": "release", + "enabled": true, + "githubSecret": "GitHub", + "repository": "https://github.com/MAKS-IT-COM/maksit-core-dapr", + "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", + "releaseTitlePattern": "Release {version}" + }, + { + "name": "DotNetNuGet", + "stageLabel": "release", + "enabled": true, + "nugetSecret": "NuGet", + "source": "https://api.nuget.org/v3/index.json" + }, + { + "name": "NpmReleaseVersion", + "stageLabel": "build", + "enabled": false, + "packageJsonPath": "..\\..\\..\\src\\package.json", + "syncWorkspaceVersions": true + }, + { + "name": "NpmBuild", + "stageLabel": "build", + "enabled": false, + "workspaceRoot": "..\\..\\..\\src", + "useCi": true, + "buildScript": "build" + }, + { + "name": "NpmPublish", + "stageLabel": "release", + "enabled": false, + "npmSecret": "Npm", + "registry": "https://registry.npmjs.org", + "access": "public", + "workspaceRoot": "..\\..\\..\\src", + "publishOrder": [] + }, + { + "name": "DotNetCleanupArtifacts", + "stageLabel": "release", + "enabled": true, + "includePatterns": [ + "*" + ], + "excludePatterns": [ + "*.zip" + ] + } + ] +} + diff --git a/src/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 similarity index 100% rename from src/engines/test/Invoke-TestEngine.ps1 rename to utils/engines/test/Invoke-TestEngine.ps1 diff --git a/src/engines/test/custom/.gitkeep b/utils/engines/test/custom/.gitkeep similarity index 100% rename from src/engines/test/custom/.gitkeep rename to utils/engines/test/custom/.gitkeep diff --git a/src/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json similarity index 78% rename from src/engines/test/scriptSettings.json rename to utils/engines/test/scriptSettings.json index 44d5676..3d0c51f 100644 --- a/src/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -2,17 +2,15 @@ "$schema": "https://json-schema.org/draft-07/schema", "title": "Run Tests Script Settings", "description": "Test engine and coverage badges for maksit-dapr.", - "paths": { - "badgesDir": "..\\..\\..\\assets\\badges" - }, "plugins": [ { "name": "DotNetTest", "stageLabel": "test", "enabled": true, "projects": [ - "..\\..\\MaksIT.Dapr.Tests" - ] + "..\\..\\..\\src\\MaksIT.Dapr.Tests" + ], + "resultsDir": "..\\..\\..\\test-results" }, { "name": "QualityGate", @@ -25,20 +23,18 @@ "name": "CoverageBadges", "stageLabel": "report", "enabled": true, - "badgesDir": "..\\..\\..\\assets\\badges", + "badgeFormat": "shields", + "readmePath": "..\\..\\..\\README.md", "badges": [ { - "name": "coverage-lines.svg", "label": "Line Coverage", "metric": "line" }, { - "name": "coverage-branches.svg", "label": "Branch Coverage", "metric": "branch" }, { - "name": "coverage-methods.svg", "label": "Method Coverage", "metric": "method" } @@ -52,5 +48,9 @@ "red": 0 } } - ] + ], + "paths": { + "readmePath": "..\\..\\..\\README.md", + "testResultsDir": "..\\..\\..\\test-results" + } } diff --git a/src/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 similarity index 100% rename from src/modules/ChangelogSupport.psm1 rename to utils/modules/ChangelogSupport.psm1 diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 new file mode 100644 index 0000000..3b94fa9 --- /dev/null +++ b/utils/modules/Engine/EngineContext.psm1 @@ -0,0 +1,332 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Generic engine helpers: path resolution and the shared context facts API. + +.DESCRIPTION + Engine-owned state stays on the context object (version, tag, skipPublishPlugins, …). + Plugin-published values go under $context.facts[namespace][name] via Set/Get/Test-EngineFact. + During migration, -LegacyProperty on Set dual-writes flat properties; Get falls back to them. + + Version plugins declare providesVersion = $true; New-EngineContext discovers the single enabled one. +#> + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +$script:EngineStateAllowlist = @( + 'version' + 'tag' + 'skipPublishPlugins' + 'releaseDir' + 'artifactsDirectory' + 'deployMode' + 'orchestrator' +) + +function Resolve-RelativePaths { + param( + [Parameter(Mandatory = $true)] + [object]$Value, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + if ($null -eq $Value) { + return @() + } + + $rawPaths = @() + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { + $rawPaths += $Value + } + else { + $rawPaths += $Value + } + + $resolved = @() + foreach ($p in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$p)) { + continue + } + + $resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p))) + } + + return @($resolved) +} + +function Initialize-EngineFactsBag { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context + ) + + if (-not ($Context.PSObject.Properties.Name -contains 'facts') -or $null -eq $Context.facts) { + $Context | Add-Member -NotePropertyName facts -NotePropertyValue ([ordered]@{}) -Force + } +} + +function Assert-EngineFactNamespace { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace + ) + + if ($Namespace -eq 'engine') { + throw "Namespace 'engine' is reserved; use Set-EngineState / Get-EngineState for engine-owned fields." + } + + if ($Namespace -cnotmatch '^[a-z][a-z0-9]*$') { + throw "Invalid facts namespace '$Namespace'. Use lowercase alphanumeric starting with a letter (e.g. 'test', 'dotnet')." + } +} + +function Assert-EngineFactName { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Name -cnotmatch '^[a-zA-Z][a-zA-Z0-9_]*$') { + throw "Invalid fact name '$Name'. Use letters, digits, underscore; must start with a letter." + } +} + +function Test-EngineFactValuePresent { + param( + [AllowNull()] + $Value + ) + + if ($null -eq $Value) { + return $false + } + + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + return $true +} + +function Set-EngineFact { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value, + + [ValidateSet('Error', 'Replace', 'Keep')] + [string]$Overwrite = 'Error', + + [Parameter(Mandatory = $false)] + [string]$LegacyProperty + ) + + Assert-EngineFactNamespace -Namespace $Namespace + Assert-EngineFactName -Name $Name + Initialize-EngineFactsBag -Context $Context + + if (-not $Context.facts.Contains($Namespace)) { + $Context.facts[$Namespace] = [ordered]@{} + } + + $bag = $Context.facts[$Namespace] + $exists = $bag.Contains($Name) + if ($exists) { + if ($Overwrite -eq 'Keep') { + if (-not [string]::IsNullOrWhiteSpace($LegacyProperty) -and -not ($Context.PSObject.Properties.Name -contains $LegacyProperty)) { + $Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $bag[$Name] -Force + } + return + } + + if ($Overwrite -eq 'Error') { + throw "Fact ${Namespace}.${Name} already set (use -Overwrite Replace or Keep)." + } + } + + $bag[$Name] = $Value + + if (-not [string]::IsNullOrWhiteSpace($LegacyProperty)) { + $Context | Add-Member -NotePropertyName $LegacyProperty -NotePropertyValue $Value -Force + } +} + +function Get-EngineFact { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Default, + + [switch]$Required, + + [Parameter(Mandatory = $false)] + [string[]]$LegacyProperty + ) + + Assert-EngineFactNamespace -Namespace $Namespace + Assert-EngineFactName -Name $Name + Initialize-EngineFactsBag -Context $Context + + if ($Context.facts.Contains($Namespace) -and $Context.facts[$Namespace].Contains($Name)) { + $value = $Context.facts[$Namespace][$Name] + if (Test-EngineFactValuePresent -Value $value) { + return $value + } + } + + foreach ($propertyName in @($LegacyProperty)) { + if ([string]::IsNullOrWhiteSpace($propertyName)) { + continue + } + + if ($Context.PSObject.Properties.Name -contains $propertyName) { + $legacyValue = $Context.$propertyName + if (Test-EngineFactValuePresent -Value $legacyValue) { + return $legacyValue + } + } + } + + if ($Required) { + throw "Required fact ${Namespace}.${Name} is missing." + } + + if ($PSBoundParameters.ContainsKey('Default')) { + return $Default + } + + return $null +} + +function Test-EngineFact { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Namespace, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + [string[]]$LegacyProperty + ) + + $value = Get-EngineFact -Context $Context -Namespace $Namespace -Name $Name -LegacyProperty $LegacyProperty + return (Test-EngineFactValuePresent -Value $value) +} + +function Set-EngineState { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value + ) + + if ($script:EngineStateAllowlist -notcontains $Name) { + throw "Engine state key '$Name' is not allowlisted. Use Set-EngineFact for plugin outputs, or extend the engine allowlist for documented engine fields." + } + + $Context | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force +} + +function Add-EnginePublishCompletion { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Publisher + ) + + if ([string]::IsNullOrWhiteSpace($Publisher)) { + throw "Publisher name is required." + } + + $completedBy = @(Get-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Default @()) + if ($completedBy -notcontains $Publisher) { + $completedBy += $Publisher + } + + Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completedBy' -Value $completedBy -Overwrite Replace + Set-EngineFact -Context $Context -Namespace 'publish' -Name 'completed' -Value $true -Overwrite Replace -LegacyProperty 'publishCompleted' +} + +function Get-EngineState { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Default, + + [switch]$Required + ) + + if ($script:EngineStateAllowlist -notcontains $Name) { + throw "Engine state key '$Name' is not allowlisted." + } + + if ($Context.PSObject.Properties.Name -contains $Name) { + $value = $Context.$Name + if (Test-EngineFactValuePresent -Value $value) { + return $value + } + } + + if ($Required) { + throw "Required engine state '$Name' is missing." + } + + if ($PSBoundParameters.ContainsKey('Default')) { + return $Default + } + + return $null +} + +Export-ModuleMember -Function ` + Resolve-RelativePaths, ` + Initialize-EngineFactsBag, ` + Set-EngineFact, ` + Get-EngineFact, ` + Test-EngineFact, ` + Set-EngineState, ` + Add-EnginePublishCompletion, ` + Get-EngineState diff --git a/src/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 similarity index 84% rename from src/modules/Engine/Import-EngineModules.ps1 rename to utils/modules/Engine/Import-EngineModules.ps1 index 5c43ed7..a557a58 100644 --- a/src/modules/Engine/Import-EngineModules.ps1 +++ b/utils/modules/Engine/Import-EngineModules.ps1 @@ -32,4 +32,9 @@ function Import-EngineModules { Import-Module $modulePath -Force } + + $extensionImport = Join-Path $modulesDir 'Extensions' 'Import-ExtensionModules.ps1' + if (Test-Path -LiteralPath $extensionImport -PathType Leaf) { + . $extensionImport + } } diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 new file mode 100644 index 0000000..8d8c482 --- /dev/null +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -0,0 +1,734 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-RepoUtilsSrcDirectory { + return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) +} + +function Get-RepoUtilsModulesDirectory { + return Split-Path $PSScriptRoot -Parent +} + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function Test-IsEngineRuntimeModuleName { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName + ) + + # Engine runtime under modules/ only — never dual-homed under plugins/. + $engineNames = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@( + 'ChangelogSupport', + 'ExternalCommandSupport', + 'GitTools', + 'Logging', + 'ScriptConfig', + 'TestRunner', + 'EngineContext', + 'PluginSupport', + 'ReleaseSupport', + 'TestSupport', + 'DeployConfig', + 'EngineContextSupport', + 'OrchestratorSupport', + 'PluginPathSupport' + ), + [System.StringComparer]::OrdinalIgnoreCase + ) + + return $engineNames.Contains($ModuleName) +} + +function Import-PluginDependency { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName, + + [Parameter(Mandatory = $true)] + [string]$RequiredCommand + ) + + if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) { + return + } + + $modulesDir = Get-RepoUtilsModulesDirectory + $engineModuleDir = $PSScriptRoot + $srcDir = Get-RepoUtilsSrcDirectory + $pluginsRoot = Join-Path $srcDir 'plugins' + $candidatePaths = [System.Collections.Generic.List[string]]::new() + + if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) { + # Engine runtime: modules/ only (no plugins/ fallback). + $candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1")) + $candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1")) + $extensionsDir = Join-Path $modulesDir 'Extensions' + $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) + } + else { + # Plugin helpers: plugins/ only (no modules/ legacy shadow). + foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1")) + } + } + + foreach ($modulePath in $candidatePaths) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global -ErrorAction Stop + break + } + } + + if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { + throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'." + } +} + +function Get-ConfiguredPlugins { + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) { + return @() + } + + if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) { + return @($Settings.plugins) + } + + return @($Settings.plugins) +} + +function Get-PluginStageLabel { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) { + return 'release' + } + + return [string]$Plugin.stageLabel +} + +function Get-PluginBranches { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) { + return @() + } + + if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) { + return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) { + return @() + } + + return @([string]$Plugin.branches) +} + +function Test-PluginAllowedOnBranch { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + $allowedBranches = Get-PluginBranches -Plugin $Plugin + if ($allowedBranches.Count -eq 0) { + return $true + } + + if ($allowedBranches -contains '*') { + return $true + } + + return $allowedBranches -contains $CurrentBranch +} + +function Get-PluginMetadataObject { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $null + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $null + } + + return & $metadataCommand + } + catch { + return $null + } +} + +function Test-PluginCompatible { + <# + .SYNOPSIS + Applies an optional compatibility policy supplied by an extension. + #> + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + $extensionTest = Get-Command Test-ExtensionPluginCompatibility -ErrorAction SilentlyContinue + if ($extensionTest) { + return & $extensionTest @PSBoundParameters + } + + return $true +} + +function Test-PluginMutatesRemote { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { + return $false + } + + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) { + $EngineDirectory = [string]$Plugin.context.scriptDir + } + elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) { + $EngineDirectory = [string]$Plugin.scriptDir + } + } + + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + return $false + } + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $false + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $false + } + + $metadata = & $metadataCommand + if ($null -eq $metadata) { + return $false + } + + if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') { + return [bool]$metadata.mutatesRemote + } + } + catch { + return $false + } + + return $false +} + +function Get-SecretEnvironmentValue { + <# + .SYNOPSIS + Reads a secret value from an environment variable by logical name. + + .DESCRIPTION + Plugins never store secret material in scriptSettings.json. Settings hold a + logical name (e.g. "GitHub", "NuGet"); the process environment variable with + that same name must be set before the engine runs. + + .PARAMETER Name + Logical secret name — also the environment variable name to read. + + .OUTPUTS + System.String. The environment variable value, or $null when unset. + + .EXAMPLE + $token = Get-SecretEnvironmentValue -Name 'GitHub' + #> + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return [Environment]::GetEnvironmentVariable($Name) +} + +function Resolve-PluginSecretName { + <# + .SYNOPSIS + Resolves a logical secret name from a plugin's scriptSettings entry. + + .DESCRIPTION + Reads a string property such as githubSecret / nugetSecret / npmSecret / + containerRegistrySecret from the plugin settings object. Returns $null when + the property is missing or blank. + + .PARAMETER PluginSettings + Plugin settings object from scriptSettings.json (the enabled plugin entry). + + .PARAMETER PropertyName + Settings property that holds the logical secret name (e.g. 'githubSecret'). + + .OUTPUTS + System.String. Trimmed logical secret name, or $null. + + .EXAMPLE + $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret' + $key = Get-SecretEnvironmentValue -Name $name + #> + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $value = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value.Trim() + } + } + + return $null +} + +function Get-RegistryCredentialsFromRuntime { + <# + .SYNOPSIS + Loads container-registry username/password from a logical secret name. + + .DESCRIPTION + Looks up the environment variable named by SecretName. The value must be + Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login + and image-pull secret creation — never pass the password itself as a parameter. + + .PARAMETER SecretName + Logical secret name (environment variable name), not a password or token. + + .PARAMETER SharedSettings + Optional engine shared context (reserved for callers that thread context). + + .OUTPUTS + Hashtable with User and Password keys (decoded credential material). + + .EXAMPLE + $creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry' + # $creds.User / $creds.Password + #> + # SecretName is a logical env-var name from scriptSettings, not a password value. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'SecretName', + Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.' + )] + param( + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings + ) + + $raw = Get-SecretEnvironmentValue -Name $SecretName + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "Environment variable '$SecretName' is not set." + } + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) + } + catch { + throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + } + + $parts = $decoded -split ':', 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { + throw "Decoded '$SecretName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Resolve-EngineDirectoryFromSharedSettings { + param( + [Parameter(Mandatory = $true)] + $SharedSettings + ) + + if ($SharedSettings.PSObject.Properties.Name -contains 'engineScriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.engineScriptDir)) { + return [string]$SharedSettings.engineScriptDir + } + + return [string]$SharedSettings.scriptDir +} + +function Test-PluginSkipsRemoteMutation { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $engineDirectory = Resolve-EngineDirectoryFromSharedSettings -SharedSettings $SharedSettings + if (-not (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory)) { + return $false + } + + return ($Plugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $Plugin.dryRun -and [bool]$Plugin.dryRun) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory + ) + + return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory +} + +function Get-PluginSettingValue { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { + continue + } + + if (-not $plugin.PSObject.Properties[$PropertyName]) { + continue + } + + $value = $plugin.$PropertyName + if ($null -eq $value) { + continue + } + + if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { + continue + } + + return $value + } + + return $null +} + +function Get-PluginPathListSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $rawPaths = @() + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + + if ($null -eq $value) { + return @() + } + + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + $rawPaths += $value + } + else { + $rawPaths += $value + } + + $resolvedPaths = @() + foreach ($path in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$path)) { + continue + } + + $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path))) + } + + return @($resolvedPaths) +} + +function Get-PluginPathSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { + return $null + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value))) +} + +function Get-ArchiveNamePattern { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { + continue + } + + if (-not $plugin.enabled) { + continue + } + + if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) { + continue + } + + if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) { + return [string]$plugin.zipNamePattern + } + } + + return "release-{version}.zip" +} + +function Resolve-PluginModulePath { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent + $pluginsRoot = Join-Path $srcDir "plugins" + $pluginFileName = "{0}.psm1" -f $Plugin.name + $candidatePaths = [System.Collections.Generic.List[string]]::new() + $candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName)) + + $preferredGroups = @('Platform', 'DotNet', 'Npm') + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName)) + + if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) { + foreach ($extensionPath in Get-ExtensionPluginModulePaths -PluginsRoot $pluginsRoot -PluginFileName $pluginFileName) { + $candidatePaths.Add($extensionPath) + } + } + + foreach ($group in $preferredGroups[1..($preferredGroups.Count - 1)]) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) $pluginFileName)) + } + + $reservedPluginDirs = @($preferredGroups + @('Shared')) + if (Test-Path -LiteralPath $pluginsRoot -PathType Container) { + Get-ChildItem -LiteralPath $pluginsRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notin $reservedPluginDirs } | + Sort-Object Name | + ForEach-Object { + $candidatePaths.Add((Join-Path $_.FullName $pluginFileName)) + } + } + + foreach ($candidatePath in $candidatePaths) { + if (Test-Path $candidatePath -PathType Leaf) { + return $candidatePath + } + } + + return $candidatePaths[0] +} + +function Test-PluginRunnable { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin entry with no name." + } + return $false + } + + if (-not $Plugin.enabled) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)." + } + return $false + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $pluginModulePath -PathType Leaf)) { + if ($WriteLogs) { + Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath" + } + return $false + } + + return $true +} + +function New-PluginInvocationSettings { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $properties = @{} + foreach ($property in $Plugin.PSObject.Properties) { + $properties[$property.Name] = $property.Value + } + + $properties['context'] = $SharedSettings + return [pscustomobject]$properties +} + +function Invoke-ConfiguredPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$ContinueOnError = $false + ) + + if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + if ($Plugin.enabled) { + return $false + } + + return $true + } + + $metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory + if ($null -ne $metadata -and ($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) { + $versionAlreadySet = $false + if (Get-Command Get-EngineState -ErrorAction SilentlyContinue) { + $existingVersion = Get-EngineState -Context $SharedSettings -Name 'version' -ErrorAction SilentlyContinue + $versionAlreadySet = -not [string]::IsNullOrWhiteSpace([string]$existingVersion) + } + elseif (($SharedSettings.PSObject.Properties.Name -contains 'version') -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.version)) { + $versionAlreadySet = $true + } + + if ($versionAlreadySet) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (version already resolved during New-EngineContext)." + return $true + } + + # Test engine (and other hosts) may not resolve version in New-EngineContext; run the plugin now. + } + + if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)." + return $true + } + + if (-not (Test-PluginCompatible -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + return $true + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." + + try { + $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings + + & $invokeCommand -Settings $pluginSettings + Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed." + return $true + } + catch { + Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)" + return $false + } +} + +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 new file mode 100644 index 0000000..0de88e4 --- /dev/null +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -0,0 +1,268 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$modulesDir = Split-Path $PSScriptRoot -Parent + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path $modulesDir "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) { + $gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1" + if (Test-Path $gitToolsModulePath -PathType Leaf) { + Import-Module $gitToolsModulePath -Force + } +} + +if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +if (-not (Get-Command Resolve-RelativePaths -ErrorAction SilentlyContinue) -or -not (Get-Command Set-EngineState -ErrorAction SilentlyContinue)) { + $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" + if (Test-Path $engineContextModulePath -PathType Leaf) { + Import-Module $engineContextModulePath -Force + } +} + +function Get-EnabledVersionPlugins { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $versionPlugins = @() + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace([string]$plugin.name)) { + continue + } + + if (-not $plugin.enabled) { + continue + } + + $metadata = Get-PluginMetadataObject -Plugin $plugin -EngineDirectory $ScriptDir + if ($null -eq $metadata) { + continue + } + + if (($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) { + $versionPlugins += $plugin + } + } + + return @($versionPlugins) +} + +function Invoke-VersionPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + throw "Version plugin '$($Plugin.name)' module not found at: $modulePath" + } + + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $Context + & $invokeCommand -Settings $pluginSettings +} + +function Resolve-EngineContextVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $versionPlugins = @(Get-EnabledVersionPlugins -Plugins $Plugins -ScriptDir $ScriptDir) + if ($versionPlugins.Count -eq 0) { + throw "Configure exactly one enabled release version plugin (declares providesVersion in Get-PluginMetadata), e.g. DotNetReleaseVersion (projectFiles), NpmReleaseVersion (packageJsonPath), or FileReleaseVersion (versionFilePath)." + } + + if ($versionPlugins.Count -gt 1) { + $names = ($versionPlugins | ForEach-Object { [string]$_.name }) -join ', ' + throw "Configure only one enabled release version plugin. Found: $names." + } + + $versionPlugin = $versionPlugins[0] + Invoke-VersionPlugin -Plugin $versionPlugin -Context $Context -EngineDirectory $ScriptDir + + $version = Get-EngineState -Context $Context -Name 'version' + if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version)) { + throw "Version plugin '$($versionPlugin.name)' did not set a version on the engine context." + } + + return [string]$versionPlugin.name +} + +function Assert-WorkingTreeClean { + $gitStatus = Get-GitStatusShort + if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) { + Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)." + foreach ($line in @([string]$gitStatus -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($line)) { + Write-Log -Level "WARN" -Message " $line" + } + } + return + } + + Write-Log -Level "OK" -Message " Working directory is clean." +} + +function Initialize-ReleaseStageContext { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory + ) + + if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) { + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $SharedSettings -Name 'releaseDir' -Value $ArtifactsDirectory + } + else { + $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force + } + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$SrcDir, + + [Parameter(Mandatory = $false)] + [psobject]$Settings, + + [Parameter(Mandatory = $false)] + [psobject]$ExtensionData + ) + + $releaseRelative = '..\..\..\releases' + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative)) + + $currentBranch = Get-CurrentBranch + + $releaseBranches = @() + foreach ($p in $Plugins) { + if (-not $p.enabled) { continue } + if ([string]$p.name -ne 'ReleasePublishGuard') { continue } + foreach ($b in (Get-PluginBranches -Plugin $p)) { + $releaseBranches += $b + } + } + $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + if ($releaseBranches.Count -eq 0) { + foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) { + if (-not $p.enabled) { continue } + foreach ($b in (Get-PluginBranches -Plugin $p)) { + $releaseBranches += $b + } + } + $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + } + if ($releaseBranches.Count -eq 0) { + $releaseBranches = @('main') + } + + $isNonReleaseBranch = -not ($releaseBranches -contains $currentBranch) + + Assert-WorkingTreeClean + + $context = [pscustomobject]@{ + scriptDir = $ScriptDir + srcDir = $SrcDir + utilsDir = $SrcDir + currentBranch = $currentBranch + artifactsDirectory = $artifactsDirectory + isNonReleaseBranch = $isNonReleaseBranch + releaseBranches = $releaseBranches + skipPublishPlugins = $false + facts = [ordered]@{} + } + + $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir + $version = [string](Get-EngineState -Context $context -Name 'version' -Required) + $tag = "v$version" + Set-EngineState -Context $context -Name 'tag' -Value $tag + Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." + + $dryRunPlugins = @( + $Plugins | + Where-Object { + $_.enabled -and + ($_.PSObject.Properties.Name -contains 'dryRun') -and + $null -ne $_.dryRun -and + [bool]$_.dryRun -and + (Test-PluginMutatesRemote -Plugin $_ -EngineDirectory $ScriptDir) + } | + ForEach-Object { [string]$_.name } + ) + if ($dryRunPlugins.Count -gt 0) { + Write-Log -Level "INFO" -Message " Plugin dryRun (validate only): $($dryRunPlugins -join ', ')" + } + + $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue + if ($expandContext) { + $expandParams = @{ + Context = $context + ScriptDir = $ScriptDir + Settings = $Settings + } + if ($PSBoundParameters.ContainsKey('ExtensionData')) { + $expandParams['ExtensionData'] = $ExtensionData + } + + return & $expandContext @expandParams + } + + return $context +} + +function Get-PreferredReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [psobject]$EngineContext + ) + + if ($EngineContext.releaseBranches.Count -gt 0) { + return $EngineContext.releaseBranches[0] + } + + return "main" +} + +Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 new file mode 100644 index 0000000..b49b60b --- /dev/null +++ b/utils/modules/Engine/TestSupport.psm1 @@ -0,0 +1,59 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$modulesDir = Split-Path $PSScriptRoot -Parent + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path $modulesDir "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Initialize-EngineFactsBag -ErrorAction SilentlyContinue)) { + $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" + if (Test-Path $engineContextModulePath -PathType Leaf) { + Import-Module $engineContextModulePath -Force + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$SrcDir, + + [Parameter(Mandatory = $false)] + [psobject]$Settings, + + [Parameter(Mandatory = $false)] + [psobject]$ExtensionData + ) + + $context = [pscustomobject]@{ + scriptDir = $ScriptDir + srcDir = $SrcDir + utilsDir = $SrcDir + facts = [ordered]@{} + } + + $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue + if ($expandContext) { + $expandParams = @{ + Context = $context + ScriptDir = $ScriptDir + Settings = $Settings + } + if ($PSBoundParameters.ContainsKey('ExtensionData')) { + $expandParams['ExtensionData'] = $ExtensionData + } + + return & $expandContext @expandParams + } + + return $context +} + +Export-ModuleMember -Function New-EngineContext diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 new file mode 100644 index 0000000..b6c05f1 --- /dev/null +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -0,0 +1,102 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$script:ExternalCommandTestHandler = $null +$script:ExternalCommandAvailability = @{} + +function Set-ExternalCommandTestHandler { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Handler + ) + + $script:ExternalCommandTestHandler = $Handler +} + +function Clear-ExternalCommandTestHandler { + $script:ExternalCommandTestHandler = $null +} + +function Set-ExternalCommandAvailability { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Availability + ) + + $script:ExternalCommandAvailability = @{} + foreach ($key in $Availability.Keys) { + $script:ExternalCommandAvailability[[string]$key] = [bool]$Availability[$key] + } +} + +function Invoke-ExternalCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$ArgumentList = @(), + + [string]$WorkingDirectory, + + [string]$InputObject, + + [switch]$MergeErrorOutput + ) + + $previousLocation = $null + if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) { + $previousLocation = Get-Location + Push-Location $WorkingDirectory + } + + try { + $effectiveWorkingDirectory = (Get-Location).Path + + if ($null -ne $script:ExternalCommandTestHandler) { + $handlerResult = & $script:ExternalCommandTestHandler ` + -Name $Name ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $effectiveWorkingDirectory ` + -InputObject $InputObject ` + -MergeErrorOutput:$MergeErrorOutput.IsPresent + + $global:LASTEXITCODE = [int]$handlerResult.ExitCode + if ($null -eq $handlerResult.Output) { + return @() + } + + if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { + return @($handlerResult.Output) + } + + return @([string]$handlerResult.Output) + } + + if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) { + throw "External command '$Name' is marked unavailable." + } + + if (-not [string]::IsNullOrWhiteSpace($InputObject)) { + $output = $InputObject | & $Name @ArgumentList 2>&1 + } + elseif ($MergeErrorOutput) { + $output = & $Name @ArgumentList 2>&1 + } + else { + $output = & $Name @ArgumentList + } + + return @($output) + } + finally { + if ($null -ne $previousLocation) { + Pop-Location + } + } +} + +Export-ModuleMember -Function ` + Invoke-ExternalCommand, ` + Set-ExternalCommandTestHandler, ` + Clear-ExternalCommandTestHandler, ` + Set-ExternalCommandAvailability diff --git a/src/modules/GitTools.psm1 b/utils/modules/GitTools.psm1 similarity index 86% rename from src/modules/GitTools.psm1 rename to utils/modules/GitTools.psm1 index be56c94..e13050c 100644 --- a/src/modules/GitTools.psm1 +++ b/utils/modules/GitTools.psm1 @@ -51,7 +51,36 @@ function Invoke-GitInternal { [string]$ErrorMessage = "Git command failed" ) + if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path $PSScriptRoot -Parent + $externalCandidates = @( + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), + (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + ) + foreach ($externalModule in $externalCandidates) { + if (Test-Path -LiteralPath $externalModule -PathType Leaf) { + Import-Module $externalModule -Global + break + } + } + } + if ($CaptureOutput) { + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + $output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } + + if ($null -eq $output) { + return "" + } + + return ($output -join "`n").Trim() + } + $output = & git @Arguments 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { @@ -66,7 +95,13 @@ function Invoke-GitInternal { return ($output -join "`n").Trim() } - & git @Arguments + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null + } + else { + & git @Arguments + } + $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { Write-Error "$ErrorMessage (exit code: $exitCode)" diff --git a/src/modules/Logging.psm1 b/utils/modules/Logging.psm1 similarity index 100% rename from src/modules/Logging.psm1 rename to utils/modules/Logging.psm1 diff --git a/src/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 similarity index 58% rename from src/modules/ScriptConfig.psm1 rename to utils/modules/ScriptConfig.psm1 index 26bd953..3cdbb35 100644 --- a/src/modules/ScriptConfig.psm1 +++ b/utils/modules/ScriptConfig.psm1 @@ -2,33 +2,33 @@ #requires -PSEdition Core function Get-ScriptSettings { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ScriptDir, [Parameter(Mandatory = $false)] - [string]$SettingsFileName = "scriptSettings.json" + [string]$SettingsFileName = 'scriptSettings.json' ) $settingsPath = Join-Path $ScriptDir $SettingsFileName - if (-not (Test-Path $settingsPath -PathType Leaf)) { - Write-Error "Settings file not found: $settingsPath" - exit 1 + if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { + throw "Settings file not found: $settingsPath" } - return Get-Content $settingsPath -Raw | ConvertFrom-Json + return Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } function Assert-Command { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Command ) if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { - Write-Error "Required command '$Command' is missing. Aborting." - exit 1 + throw "Required command '$Command' is missing. Aborting." } } diff --git a/src/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 similarity index 50% rename from src/modules/TestRunner.psm1 rename to utils/modules/TestRunner.psm1 index 395acf4..b1935e2 100644 --- a/src/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -14,6 +14,28 @@ Usage: pwsh -Command "Import-Module .\TestRunner.psm1" #> +function Import-ExternalCommandSupportInternal { + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + return + } + + $srcDir = Split-Path $PSScriptRoot -Parent + $candidates = @( + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), + (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + ) + foreach ($modulePath in $candidates) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global + break + } + } + + if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { + throw "ExternalCommandSupport module not found. Expected: $($candidates -join ', ')" + } +} + function Import-LoggingModuleInternal { if (Get-Command Write-Log -ErrorAction SilentlyContinue) { return @@ -151,11 +173,12 @@ function Invoke-TestsWithCoverage { "--verbosity", $(if ($Silent) { "quiet" } else { "normal" }) ) + Import-ExternalCommandSupportInternal if ($Silent) { - $null = & dotnet @dotnetArgs 2>&1 + $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput } else { - & dotnet @dotnetArgs + Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default } $testExitCode = $LASTEXITCODE @@ -331,11 +354,12 @@ function Invoke-NpmJestTestsWithCoverage { Push-Location $workspaceFull try { $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') + Import-ExternalCommandSupportInternal if ($Silent) { - $null = & npm @npmArgs 2>&1 + $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput } else { - & npm @npmArgs + Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default } if ($LASTEXITCODE -ne 0) { @@ -389,4 +413,272 @@ function Invoke-NpmJestTestsWithCoverage { } } -Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage +function Get-NpmCoverageFromSummaryFile { + param( + [Parameter(Mandatory = $true)] + [string]$SummaryPath, + + [switch]$Silent + ) + + if (-not (Test-Path -LiteralPath $SummaryPath -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "Jest coverage summary not found at: $SummaryPath" + } + } + + $summaryJson = Get-Content -LiteralPath $SummaryPath -Raw -Encoding UTF8 | ConvertFrom-Json + $total = $summaryJson.total + if ($null -eq $total) { + return [PSCustomObject]@{ + Success = $false + Error = "Jest coverage summary is missing 'total' metrics in: $SummaryPath" + } + } + + return [PSCustomObject]@{ + Success = $true + LineRate = [math]::Round([double]$total.lines.pct, 1) + BranchRate = [math]::Round([double]$total.branches.pct, 1) + MethodRate = [math]::Round([double]$total.functions.pct, 1) + TotalMethods = [int]$total.functions.total + CoveredMethods = [int]$total.functions.covered + CoverageFormat = 'npm' + CoverageSummaryFile = $SummaryPath + } +} + +function Get-DotNetCoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [switch]$Silent + ) + + $coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($coverageFiles.Count -eq 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $ResultsDirectory" + } + } + + $linesCoveredTotal = 0L + $linesValidTotal = 0L + $branchesCoveredTotal = 0L + $branchesValidTotal = 0L + $totalMethods = 0 + $coveredMethods = 0 + + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $root = $coverageXml.coverage + $lcAttr = $root.'lines-covered' + $lvAttr = $root.'lines-valid' + if ($null -ne $lcAttr -and $null -ne $lvAttr -and [long]$lvAttr -gt 0) { + $linesCoveredTotal += [long]$lcAttr + $linesValidTotal += [long]$lvAttr + } + + $bcAttr = $root.'branches-covered' + $bvAttr = $root.'branches-valid' + if ($null -ne $bcAttr -and $null -ne $bvAttr -and [long]$bvAttr -gt 0) { + $branchesCoveredTotal += [long]$bcAttr + $branchesValidTotal += [long]$bvAttr + } + + foreach ($package in @($root.packages.package)) { + foreach ($class in @($package.classes.class)) { + $methodNodes = $class.methods + if ($null -eq $methodNodes) { continue } + foreach ($method in @($methodNodes.method)) { + if ($null -eq $method) { continue } + $totalMethods++ + if ([double]$method.'line-rate' -gt 0) { + $coveredMethods++ + } + } + } + } + } + + if ($linesValidTotal -gt 0) { + $lineRate = [math]::Round(($linesCoveredTotal / $linesValidTotal) * 100, 1) + } + else { + $acc = 0.0 + $n = 0 + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $acc += [double]$coverageXml.coverage.'line-rate' + $n++ + } + $lineRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1) + } + + if ($branchesValidTotal -gt 0) { + $branchRate = [math]::Round(($branchesCoveredTotal / $branchesValidTotal) * 100, 1) + } + else { + $acc = 0.0 + $n = 0 + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $acc += [double]$coverageXml.coverage.'branch-rate' + $n++ + } + $branchRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1) + } + + $methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 } + $coveragePaths = @($coverageFiles | ForEach-Object { $_.FullName }) + + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageFile = ($coveragePaths -join ';') + CoverageFiles = $coveragePaths + ResultsDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + CoverageFormat = 'dotnet' + } +} + +function Get-CoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [ValidateSet('auto', 'dotnet', 'npm')] + [string]$Format = 'auto', + + [switch]$Silent + ) + + $resolvedDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + if (-not (Test-Path -LiteralPath $resolvedDirectory -PathType Container)) { + return [PSCustomObject]@{ + Success = $false + Error = "Results directory not found: $resolvedDirectory" + } + } + + $hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + $jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json' + $hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf + + $effectiveFormat = $Format + if ($effectiveFormat -eq 'auto') { + if ($hasDotNet -and -not $hasNpm) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm -and -not $hasDotNet) { + $effectiveFormat = 'npm' + } + elseif ($hasDotNet) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm) { + $effectiveFormat = 'npm' + } + else { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $resolvedDirectory" + } + } + } + + if ($effectiveFormat -eq 'dotnet') { + return Get-DotNetCoverageFromResultsDirectory -ResultsDirectory $resolvedDirectory -Silent:$Silent + } + + if ($effectiveFormat -eq 'npm') { + $npmResult = Get-NpmCoverageFromSummaryFile -SummaryPath $jestSummary -Silent:$Silent + if (-not $npmResult.Success) { + return $npmResult + } + + $npmResult | Add-Member -NotePropertyName ResultsDirectory -NotePropertyValue $resolvedDirectory -Force + return $npmResult + } + + return [PSCustomObject]@{ + Success = $false + Error = "Unsupported coverage format '$Format'." + } +} + +function Publish-CoverageMetricsToSharedContext { + param( + [Parameter(Mandatory = $true)] + $SharedSettings, + + [Parameter(Mandatory = $true)] + $TestResult + ) + + if (-not (Get-Command Set-EngineFact -ErrorAction SilentlyContinue)) { + $engineContextPath = Join-Path $PSScriptRoot 'Engine' 'EngineContext.psm1' + if (Test-Path -LiteralPath $engineContextPath -PathType Leaf) { + Import-Module $engineContextPath -Force -Global + } + } + + if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResult' -Value $TestResult -Overwrite Replace -LegacyProperty 'testResult' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageLineRate' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'coverageLineRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'qualityLineCoverage' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'qualityLineCoverage' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageBranchRate' -Value $TestResult.BranchRate -Overwrite Replace -LegacyProperty 'coverageBranchRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageMethodRate' -Value $TestResult.MethodRate -Overwrite Replace -LegacyProperty 'coverageMethodRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageTotalMethods' -Value $TestResult.TotalMethods -Overwrite Replace -LegacyProperty 'coverageTotalMethods' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoveredMethods' -Value $TestResult.CoveredMethods -Overwrite Replace -LegacyProperty 'coverageCoveredMethods' + + if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageFormat' -Value $TestResult.CoverageFormat -Overwrite Replace -LegacyProperty 'coverageFormat' + } + + if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResultsDirectory' -Value $TestResult.ResultsDirectory -Overwrite Replace -LegacyProperty 'testResultsDirectory' + } + + if ($TestResult.CoverageFiles) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoberturaPaths' -Value @($TestResult.CoverageFiles) -Overwrite Replace -LegacyProperty 'coverageCoberturaPaths' + } + + return + } + + $SharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $TestResult -Force + $SharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $TestResult.LineRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $TestResult.LineRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $TestResult.BranchRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $TestResult.MethodRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $TestResult.TotalMethods -Force + $SharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $TestResult.CoveredMethods -Force + + if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') { + $SharedSettings | Add-Member -NotePropertyName coverageFormat -NotePropertyValue $TestResult.CoverageFormat -Force + } + + if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) { + $SharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $TestResult.ResultsDirectory -Force + } + + if ($TestResult.CoverageFiles) { + $SharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($TestResult.CoverageFiles) -Force + } +} + +Export-ModuleMember -Function ` + Invoke-TestsWithCoverage, ` + Invoke-NpmJestTestsWithCoverage, ` + Get-NpmCoverageFromSummaryFile, ` + Get-DotNetCoverageFromResultsDirectory, ` + Get-CoverageFromResultsDirectory, ` + Publish-CoverageMetricsToSharedContext diff --git a/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 new file mode 100644 index 0000000..f2f0ed6 --- /dev/null +++ b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 @@ -0,0 +1,69 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Discovers existing .NET NuGet package artifacts. + +.DESCRIPTION + Scans artifactsDir for .nupkg/.snupkg matching the release version and populates shared + context (packageFile, symbolsPackageFile, releaseArchiveInputs) for downstream plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts' + Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Set-EngineFact' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + + if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + } + else { + $artifactsDirectory = $sharedSettings.artifactsDirectory + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw 'DiscoverDotNetPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.' + } + + Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..." + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version + + Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" + } + else { + Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version." + } + + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetArtifactSupport.psm1 b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 new file mode 100644 index 0000000..205406e --- /dev/null +++ b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 @@ -0,0 +1,63 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Shared helpers for discovering .NET NuGet package artifacts on disk. +#> + +function Resolve-DotNetPackageArtifacts { + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + if (-not (Test-Path $ArtifactsDirectory -PathType Container)) { + throw "Artifacts directory not found: $ArtifactsDirectory" + } + + $packageFile = $null + $newestNupkgWrite = [datetime]::MinValue + $nupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.nupkg' + foreach ($candidate in $nupkgCandidates) { + if (($candidate.Name -like "*$Version*.nupkg") -and ($candidate.Name -notlike '*.symbols.nupkg') -and ($candidate.Name -notlike '*.snupkg')) { + if ($candidate.LastWriteTime -gt $newestNupkgWrite) { + $newestNupkgWrite = $candidate.LastWriteTime + $packageFile = $candidate + } + } + } + + if (-not $packageFile) { + throw "Could not locate generated NuGet package for version $Version in: $ArtifactsDirectory" + } + + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = $null + $newestSnupkgWrite = [datetime]::MinValue + $snupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.snupkg' + foreach ($candidate in $snupkgCandidates) { + if ($candidate.Name -like "*$Version*.snupkg") { + if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { + $newestSnupkgWrite = $candidate.LastWriteTime + $symbolsPackageFile = $candidate + } + } + } + + if ($symbolsPackageFile) { + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + + return [pscustomobject]@{ + PackageFile = $packageFile + SymbolsPackageFile = $symbolsPackageFile + ReleaseArchiveInputs = $releaseArchiveInputs + } +} + +Export-ModuleMember -Function Resolve-DotNetPackageArtifacts diff --git a/src/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 similarity index 100% rename from src/plugins/DotNet/DotNetCleanupArtifacts.psm1 rename to utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 diff --git a/src/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 similarity index 59% rename from src/plugins/DotNet/DotNetCreateArchive.psm1 rename to utils/plugins/DotNet/DotNetCreateArchive.psm1 index 92b34bd..e921189 100644 --- a/src/plugins/DotNet/DotNetCreateArchive.psm1 +++ b/utils/plugins/DotNet/DotNetCreateArchive.psm1 @@ -26,6 +26,7 @@ function Invoke-Plugin { ) Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context @@ -33,13 +34,18 @@ function Invoke-Plugin { $version = $sharedSettings.version $archiveInputs = @() - if ($sharedSettings.PSObject.Properties['releaseArchiveInputs'] -and $sharedSettings.releaseArchiveInputs) { - $archiveInputs = @($sharedSettings.releaseArchiveInputs) + $fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs') + if ($null -ne $fromFact) { + $archiveInputs = @($fromFact) } - elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { - $archiveInputs = @($sharedSettings.packageFile.FullName) - if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { - $archiveInputs += $sharedSettings.symbolsPackageFile.FullName + else { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -ne $packageFile) { + $archiveInputs = @($packageFile.FullName) + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $archiveInputs += $symbolsPackageFile.FullName + } } } @@ -79,16 +85,18 @@ function Invoke-Plugin { Write-Log -Level "OK" -Message " Release archive ready: $zipPath" $releaseAssetPaths = @($zipPath) - if ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { - $releaseAssetPaths += $sharedSettings.packageFile.FullName + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -ne $packageFile) { + $releaseAssetPaths += $packageFile.FullName } - if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { - $releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $releaseAssetPaths += $symbolsPackageFile.FullName } - $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force - $sharedSettings | Add-Member -NotePropertyName releaseArchivePath -NotePropertyValue $zipPath -Force - $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' } Export-ModuleMember -Function Invoke-Plugin diff --git a/src/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 similarity index 55% rename from src/plugins/DotNet/DotNetNuGet.psm1 rename to utils/plugins/DotNet/DotNetNuGet.psm1 index ecb2eb0..ef08a81 100644 --- a/src/plugins/DotNet/DotNetNuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -29,22 +29,36 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' $packageFile = $sharedSettings.packageFile + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + Assert-Command dotnet if (-not $packageFile) { throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet." } - if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { - throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json." + if ($dryRun) { + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource" + return } - $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { - throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + if ([string]::IsNullOrWhiteSpace($nugetSecret)) { + throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." + } + + $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret + if ([string]::IsNullOrWhiteSpace($nugetKey)) { + throw "NuGet API key is not set. Set environment variable '$nugetSecret'." } $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { @@ -55,17 +69,22 @@ function Invoke-Plugin { } Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." - dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "Failed to push the package to NuGet feed." } Write-Log -Level "OK" -Message " NuGet push completed." - $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" + Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'DotNetNuGet' } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/src/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 similarity index 61% rename from src/plugins/DotNet/DotNetPack.psm1 rename to utils/plugins/DotNet/DotNetPack.psm1 index bffc191..5929045 100644 --- a/src/plugins/DotNet/DotNetPack.psm1 +++ b/utils/plugins/DotNet/DotNetPack.psm1 @@ -30,7 +30,8 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" + Import-PluginDependency -ModuleName "DotNetArtifactSupport" -RequiredCommand "Resolve-DotNetPackageArtifacts" $sharedSettings = $Settings.context $scriptDir = $sharedSettings.scriptDir @@ -48,12 +49,12 @@ function Invoke-Plugin { if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory } else { $artifactsDirectory = $sharedSettings.artifactsDirectory } - $packageProjectPath = $null - $releaseArchiveInputs = @() Assert-Command dotnet @@ -79,50 +80,19 @@ function Invoke-Plugin { throw "dotnet pack failed for $packageProjectPath." } - # dotnet pack can leave older packages in the artifacts directory. - # Pick the newest file matching the current version rather than assuming a clean folder. - $packageFile = $null - $newestNupkgWrite = [datetime]::MinValue - $nupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.nupkg" - foreach ($candidate in $nupkgCandidates) { - if (($candidate.Name -like "*$version*.nupkg") -and ($candidate.Name -notlike "*.symbols.nupkg") -and ($candidate.Name -notlike "*.snupkg")) { - if ($candidate.LastWriteTime -gt $newestNupkgWrite) { - $newestNupkgWrite = $candidate.LastWriteTime - $packageFile = $candidate - } - } - } + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $outputDir -Version $version - if (-not $packageFile) { - throw "Could not locate generated NuGet package for version $version in: $outputDir" - } - - Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" - $releaseArchiveInputs = @($packageFile.FullName) - - $symbolsPackageFile = $null - $newestSnupkgWrite = [datetime]::MinValue - $snupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.snupkg" - foreach ($candidate in $snupkgCandidates) { - if ($candidate.Name -like "*$version*.snupkg") { - if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { - $newestSnupkgWrite = $candidate.LastWriteTime - $symbolsPackageFile = $candidate - } - } - } - - if ($symbolsPackageFile) { - Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)" - $releaseArchiveInputs += $symbolsPackageFile.FullName + Write-Log -Level "OK" -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" } else { Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." } - $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $packageFile -Force - $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force - $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' } Export-ModuleMember -Function Invoke-Plugin diff --git a/src/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 similarity index 74% rename from src/plugins/DotNet/DotNetPublish.psm1 rename to utils/plugins/DotNet/DotNetPublish.psm1 index 84c4ec2..d2dacba 100644 --- a/src/plugins/DotNet/DotNetPublish.psm1 +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -27,18 +27,21 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" $sharedSettings = $Settings.context - $projectFiles = $sharedSettings.projectFiles + $projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles') $artifactsDirectory = $sharedSettings.artifactsDirectory $publishProjectPath = $null Assert-Command dotnet - if (-not $sharedSettings.PSObject.Properties['projectFiles'] -or $projectFiles.Count -eq 0) { + if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) { throw "DotNetPublish plugin requires project files in the shared context." } + $projectFiles = @($projectFiles) + if (!(Test-Path $artifactsDirectory)) { New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null } @@ -64,9 +67,9 @@ function Invoke-Plugin { Write-Log -Level "OK" -Message " Published artifact ready: $publishDir" - $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $null -Force - $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $null -Force - $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue @($publishDir) -Force + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' } Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 new file mode 100644 index 0000000..3cb01fd --- /dev/null +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -0,0 +1,96 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version from an SDK-style .csproj into shared context. + +.DESCRIPTION + Dedicated version-loading plugin. Reads from the first configured + projectFiles entry and writes it (plus the resolved projectFiles) to the + shared runtime context. Declares providesVersion = $true so the engine can + discover it as the single release version source. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-CsprojPropertyValueInternal { + param( + [Parameter(Mandatory = $true)] + [xml]$Csproj, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + # SDK-style .csproj files can have multiple PropertyGroup nodes. + # Use the first group that defines the requested property. + $propNode = $Csproj.Project.PropertyGroup | + Where-Object { $_.$PropertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$PropertyName + } + + return $null +} + +function Get-CsprojVersionInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectPath + ) + + if (-not (Test-Path $ProjectPath -PathType Leaf)) { + throw "DotNetReleaseVersion: project file not found at '$ProjectPath'." + } + + if ([System.IO.Path]::GetExtension($ProjectPath) -ne ".csproj") { + throw "DotNetReleaseVersion: configured project file is not a .csproj file: '$ProjectPath'." + } + + [xml]$csproj = Get-Content $ProjectPath + $version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version" + + if ([string]::IsNullOrWhiteSpace([string]$version)) { + throw "DotNetReleaseVersion: not found in '$ProjectPath'." + } + + return [string]$version +} + +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" + + $shared = $Settings.context + $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir) + if ($projectFiles.Count -eq 0) { + throw "DotNetReleaseVersion plugin requires 'projectFiles' (first .csproj with ) in scriptSettings.json." + } + + Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..." + $version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0] + + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles' + Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $version" +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/src/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 similarity index 71% rename from src/plugins/DotNet/DotNetTest.psm1 rename to utils/plugins/DotNet/DotNetTest.psm1 index 188a825..b8bd09d 100644 --- a/src/plugins/DotNet/DotNetTest.psm1 +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -10,8 +10,9 @@ via TestRunner, then publishes metrics on the shared engine context for any later plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`, method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read - those keys generically (not tied to this plugin by name). Cobertura files are removed - after parsing unless TestRunner gains KeepResults. + those keys generically (not tied to this plugin by name). When `resultsDir` (or the + multi-project default TestResults folder) is used, Cobertura output is kept on disk + via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -68,6 +69,7 @@ function Invoke-Plugin { } if ($testResultsDir) { $invokeTestParams.ResultsDirectory = $testResultsDir + $invokeTestParams.KeepResults = $true } $testResult = Invoke-TestsWithCoverage @invokeTestParams @@ -76,19 +78,8 @@ function Invoke-Plugin { throw "Tests failed. $($testResult.Error)" } - $sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force - $sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force - $sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force - if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) { - $sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force - } - if ($testResult.CoverageFiles) { - $sharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($testResult.CoverageFiles) -Force - } + Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult Write-Log -Level "OK" -Message " All tests passed!" Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" diff --git a/src/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1 similarity index 100% rename from src/plugins/Npm/NpmBuild.psm1 rename to utils/plugins/Npm/NpmBuild.psm1 diff --git a/src/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1 similarity index 66% rename from src/plugins/Npm/NpmJestTest.psm1 rename to utils/plugins/Npm/NpmJestTest.psm1 index 82803c7..e89f604 100644 --- a/src/plugins/Npm/NpmJestTest.psm1 +++ b/utils/plugins/Npm/NpmJestTest.psm1 @@ -58,20 +58,12 @@ function Invoke-Plugin { throw "Tests failed. $($testResult.Error)" } - $sharedSettings | Add-Member -NotePropertyName npmWorkspaceRoot -NotePropertyValue $workspaceRoot -Force - $sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force - $sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force - $sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force - - if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) { - $sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force - } + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" + Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" + Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot' + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) { - $sharedSettings | Add-Member -NotePropertyName coverageSummaryFile -NotePropertyValue $testResult.CoverageSummaryFile -Force + Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile' } Write-Log -Level "OK" -Message " All tests passed!" diff --git a/utils/plugins/Npm/NpmPack.psm1 b/utils/plugins/Npm/NpmPack.psm1 new file mode 100644 index 0000000..7ec9945 --- /dev/null +++ b/utils/plugins/Npm/NpmPack.psm1 @@ -0,0 +1,139 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Packs npm workspace packages into .tgz release artifacts. + +.DESCRIPTION + Runs npm pack for each configured workspace package and publishes the + resulting tarball paths into shared release context (releaseAssetPaths) + for the GitHub release plugin. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" + + $pluginSettings = $Settings + $shared = $Settings.context + + Assert-Command npm + + $workspaceRoot = $null + if ($pluginSettings.workspaceRoot) { + $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) + $workspaceRoot = $workspaceRoots[0] + } + elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) { + $workspaceRoot = [string]$shared.npmWorkspaceRoot + } + else { + throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $artifactsDirectory = $null + if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir))) + Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory + } + elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) { + $artifactsDirectory = [string]$shared.artifactsDirectory + } + else { + throw "NpmPack plugin requires release-stage artifactsDirectory." + } + + $packOrder = @() + if ($pluginSettings.publishOrder) { + if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { + $packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) { + $packOrder = @([string]$pluginSettings.publishOrder) + } + } + elseif ($pluginSettings.packOrder) { + if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) { + $packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) { + $packOrder = @([string]$pluginSettings.packOrder) + } + } + + if ($packOrder.Count -eq 0) { + throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)." + } + + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $packOrder.Count -gt 1) { + throw "NpmPack plugin requires npm workspaces when packing more than one package." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $releaseAssetPaths = @() + + Push-Location $workspaceRoot + try { + foreach ($packageName in $packOrder) { + Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..." + if ($useWorkspaces) { + $tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + $tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) { + throw "npm pack failed for '$packageName'." + } + + $tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim() + if (-not (Test-Path $tarballPath -PathType Leaf)) { + throw "Could not locate pack output for '$packageName' at: $tarballPath" + } + + $strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim() + if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) { + Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue + } + + Write-Log -Level "OK" -Message " Package ready: $tarballPath" + $releaseAssetPaths += $tarballPath + } + } + finally { + Pop-Location + } + + Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory + Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' + if ($releaseAssetPaths.Count -gt 0) { + $packageItem = Get-Item -LiteralPath $releaseAssetPaths[0] + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile' + } + + Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmPackageSupport.psm1 b/utils/plugins/Npm/NpmPackageSupport.psm1 new file mode 100644 index 0000000..4b3a903 --- /dev/null +++ b/utils/plugins/Npm/NpmPackageSupport.psm1 @@ -0,0 +1,62 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-NpmRootPackageJson { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $packageJsonPath = Join-Path $WorkspaceRoot 'package.json' + if (-not (Test-Path $packageJsonPath -PathType Leaf)) { + throw "package.json not found at '$packageJsonPath'." + } + + return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Test-NpmWorkspacesConfigured { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) { + return $false + } + + $workspaces = $json.workspaces + if ($workspaces -is [string]) { + return -not [string]::IsNullOrWhiteSpace($workspaces) + } + + if ($workspaces -is [System.Collections.IEnumerable]) { + $entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + return $entries.Count -gt 0 + } + + return $false +} + +function Assert-NpmRootPackageName { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + + [Parameter(Mandatory = $true)] + [string]$ExpectedPackageName + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + $rootPackageName = [string]$json.name + if ([string]::IsNullOrWhiteSpace($rootPackageName)) { + throw "Root package.json at '$WorkspaceRoot' is missing 'name'." + } + + if ($rootPackageName -ne $ExpectedPackageName) { + throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)." + } +} + +Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson diff --git a/src/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 similarity index 62% rename from src/plugins/Npm/NpmPublish.psm1 rename to utils/plugins/Npm/NpmPublish.psm1 index 7ff8259..db6b0ec 100644 --- a/src/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,9 +6,9 @@ Publishes npm workspace packages to the npm registry. .DESCRIPTION - Publishes packages in configured order using an API key from an environment - variable (for example NPMJS_MAKS_IT). Uses a temporary .npmrc in the - workspace root for auth and supports --skip-duplicate semantics via npm. + Publishes packages in configured order using npmSecret (logical secret name). + Pass the token via an environment variable named like the configured npm secret (e.g. Npm). + Uses a temporary .npmrc in the workspace root. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -32,18 +32,10 @@ function Invoke-Plugin { $pluginSettings = $Settings $shared = $Settings.context + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + Assert-Command npm - $npmApiKeyEnvVar = $pluginSettings.npmApiKey - if ([string]::IsNullOrWhiteSpace($npmApiKeyEnvVar)) { - throw "NpmPublish plugin requires 'npmApiKey' in scriptSettings.json (environment variable name)." - } - - $npmApiKey = [System.Environment]::GetEnvironmentVariable($npmApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($npmApiKey)) { - throw "npm API key is not set. Set '$npmApiKeyEnvVar' and rerun." - } - $workspaceRoot = $null if ($pluginSettings.workspaceRoot) { $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) @@ -84,11 +76,34 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { + throw "NpmPublish plugin requires npm workspaces when publishing more than one package." + } + + if ($dryRun) { + foreach ($packageName in $publishOrder) { + Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry" + } + return + } + + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' + if ([string]::IsNullOrWhiteSpace($npmSecret)) { + throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)." + } + + $npmToken = Get-SecretEnvironmentValue -Name $npmSecret + if ([string]::IsNullOrWhiteSpace($npmToken)) { + throw "npm API key is not set. Set environment variable '$npmSecret'." + } + $registryHost = ([uri]$registry).Host $tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp" $npmRcContent = @" registry=$registry -//$registryHost/:_authToken=$npmApiKey +//$registryHost/:_authToken=$npmToken "@ Push-Location $workspaceRoot @@ -97,7 +112,14 @@ registry=$registry foreach ($packageName in $publishOrder) { Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." - npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + if ($useWorkspaces) { + npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + npm publish --access $access --userconfig $tempNpmRcPath + } + if ($LASTEXITCODE -ne 0) { throw "Failed to publish npm package '$packageName'." } @@ -105,7 +127,8 @@ registry=$registry } Write-Log -Level "OK" -Message " npm publish completed." - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" + Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish' } finally { if (Test-Path $tempNpmRcPath -PathType Leaf) { @@ -115,4 +138,8 @@ registry=$registry } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/src/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 similarity index 84% rename from src/plugins/Npm/NpmReleaseVersion.psm1 rename to utils/plugins/Npm/NpmReleaseVersion.psm1 index 3020c78..ad48e46 100644 --- a/src/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -57,6 +57,10 @@ function Set-PackageJsonVersionInternal { ($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline } +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + function Invoke-Plugin { param( [Parameter(Mandatory = $true)] @@ -64,7 +68,7 @@ function Invoke-Plugin { ) Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" $pluginSettings = $Settings $shared = $Settings.context @@ -90,10 +94,11 @@ function Invoke-Plugin { Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version." } - $shared | Add-Member -NotePropertyName version -NotePropertyValue $version -Force - $shared | Add-Member -NotePropertyName npmWorkspaceRoot -NotePropertyValue (Split-Path -Parent $packageJsonPath) -Force - $shared | Add-Member -NotePropertyName npmPackageJsonPath -NotePropertyValue $packageJsonPath -Force + $npmWorkspaceRoot = Split-Path -Parent $packageJsonPath + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot' + Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath' Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version" } -Export-ModuleMember -Function Invoke-Plugin +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/CleanupArtifacts.psm1 b/utils/plugins/Platform/CleanupArtifacts.psm1 new file mode 100644 index 0000000..ab145fa --- /dev/null +++ b/utils/plugins/Platform/CleanupArtifacts.psm1 @@ -0,0 +1,122 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Artifact cleanup plugin — remove files from the artifacts directory after release. + +.DESCRIPTION + Removes files from the configured artifacts directory using glob patterns. + When includePatterns is omitted, defaults to NuGet outputs (*.nupkg, *.snupkg). + Typically placed at the end of the Release stage after archive or publish plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-CleanupPatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @('*.nupkg', '*.snupkg') + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @('*.nupkg', '*.snupkg') + } + + return @([string]$ConfiguredPatterns) +} + +function Get-ExcludePatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @() + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @() + } + + return @([string]$ConfiguredPatterns) +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $artifactsDirectory = $sharedSettings.artifactsDirectory + $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns + $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CleanupArtifacts plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" + return + } + + Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." + + $itemsToRemove = @() + foreach ($pattern in $patterns) { + $matchedItems = @( + Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } + ) + + if ($excludePatterns.Count -gt 0) { + $matchedItems = @( + $matchedItems | + Where-Object { + $item = $_ + -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) + } + ) + } + + $itemsToRemove += @($matchedItems) + } + + $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) + + if ($itemsToRemove.Count -eq 0) { + Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." + return + } + + foreach ($item in $itemsToRemove) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Removed: $($item.Name)" + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/src/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 similarity index 58% rename from src/plugins/Platform/CoverageBadges.psm1 rename to utils/plugins/Platform/CoverageBadges.psm1 index 36ba26a..2187b9c 100644 --- a/src/plugins/Platform/CoverageBadges.psm1 +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -6,7 +6,11 @@ Coverage badge plugin for the test engine. .DESCRIPTION - Reads line/branch/method coverage from shared engine context and writes SVG badges. + Reads line/branch/method coverage from shared engine context. + + badgeFormat "svg" (default): writes SVG files under badgesDir. + badgeFormat "shields": updates readmePath (string or array) with img.shields.io markdown + (no local assets). #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -80,6 +84,80 @@ function New-BadgeSvgInternal { "@ } +function New-ShieldsIoBadgeUrlInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $labelToken = ($Label -replace ' ', '%20') + $valueToken = "$Percentage%25" + return "https://img.shields.io/badge/$labelToken-$valueToken-$Color" +} + +function New-ShieldsIoBadgeMarkdownInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color + return "![$Label]($url)" +} + +function Update-ReadmeShieldsBadgesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReadmePath, + + [Parameter(Mandatory = $true)] + [object[]]$Badges, + + [Parameter(Mandatory = $true)] + [hashtable]$Metrics, + + [Parameter(Mandatory = $true)] + [psobject]$Thresholds + ) + + if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) { + throw "CoverageBadges readmePath not found: $ReadmePath" + } + + $content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8 + + foreach ($badge in @($Badges)) { + $metricValue = $Metrics[[string]$badge.metric] + if ($null -eq $metricValue) { + throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'." + } + + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds + $markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color + # Horizontal whitespace only; optional CR for CRLF. Do not let \s* eat the following blank line. + $pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)[^\S\r\n]*\r?$" + if ($content -notmatch $pattern) { + throw "README badge line not found for label '$($badge.label)' in: $ReadmePath" + } + + $content = [regex]::Replace($content, $pattern, $markdown) + } + + $content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline +} + function Get-CoverageMetricsFromSharedContext { param( [Parameter(Mandatory = $true)] @@ -131,17 +209,9 @@ function Invoke-Plugin { $scriptDir = $sharedSettings.scriptDir $metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings - $badgesDir = $sharedSettings.badgesDir - if ($pluginSettings.badgesDir) { - $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) - $badgesDir = $badgesDirs[0] - } - if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { - throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." - } - - if (-not (Test-Path $badgesDir)) { - New-Item -ItemType Directory -Path $badgesDir | Out-Null + $badgeFormat = 'svg' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) { + $badgeFormat = [string]$pluginSettings.badgeFormat } $thresholds = $pluginSettings.colorThresholds @@ -158,10 +228,56 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Generating coverage badges..." + if ($badgeFormat -eq 'shields') { + $readmePathSetting = $null + if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) { + $readmePathSetting = $sharedSettings.readmePath + } + if ($pluginSettings.PSObject.Properties.Name -contains 'readmePath' -and $pluginSettings.readmePath) { + $readmePathSetting = $pluginSettings.readmePath + } + + $readmePaths = @() + if ($null -ne $readmePathSetting) { + $readmePaths = @(Resolve-RelativePaths -Value $readmePathSetting -BasePath $scriptDir) + } + if ($readmePaths.Count -eq 0) { + throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json." + } + + foreach ($readmePath in $readmePaths) { + Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds + Write-Log -Level "OK" -Message "README shields updated: $readmePath" + } + + foreach ($badge in @($pluginSettings.badges)) { + $metricValue = $metrics[[string]$badge.metric] + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds + Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)" + } + + Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs." + return + } + + $badgesDir = $sharedSettings.badgesDir + if ($pluginSettings.badgesDir) { + $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) + $badgesDir = $badgesDirs[0] + } + if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { + throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." + } + + if (-not (Test-Path $badgesDir)) { + New-Item -ItemType Directory -Path $badgesDir | Out-Null + } + foreach ($badge in @($pluginSettings.badges)) { $metricValue = $metrics[[string]$badge.metric] if ($null -eq $metricValue) { - throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'." + $badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label } + throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'." } $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 new file mode 100644 index 0000000..c7bb3bd --- /dev/null +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -0,0 +1,81 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version from a repo-root VERSION file into shared context. + +.DESCRIPTION + Reads a single-line semver from the configured versionFilePath (default + repo-root VERSION). Useful for repositories without .csproj or package.json + version metadata. Declares providesVersion = $true so the engine can + discover it as the single release version source. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-VersionFileSemverInternal { + param( + [Parameter(Mandatory = $true)] + [string]$VersionFilePath + ) + + if (-not (Test-Path $VersionFilePath -PathType Leaf)) { + throw "FileReleaseVersion: VERSION file not found at '$VersionFilePath'." + } + + $version = (Get-Content -Path $VersionFilePath -Raw -Encoding UTF8).Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + throw "FileReleaseVersion: VERSION file is empty at '$VersionFilePath'." + } + + $version = $version -replace '^[vV]', '' + if ($version -notmatch '^\d+\.\d+\.\d+') { + throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." + } + + return $version +} + +function Get-PluginMetadata { + return [pscustomobject]@{ providesVersion = $true } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" + + $shared = $Settings.context + $versionFileSetting = if ($Settings.versionFilePath) { + $Settings.versionFilePath + } + else { + '..\..\..\VERSION' + } + + $versionFilePaths = @(Resolve-RelativePaths -Value $versionFileSetting -BasePath $shared.scriptDir) + if ($versionFilePaths.Count -eq 0) { + throw "FileReleaseVersion plugin requires 'versionFilePath' (repo-root VERSION file) in scriptSettings.json." + } + + $versionFilePath = $versionFilePaths[0] + Write-Log -Level "INFO" -Message "Reading version from VERSION file (versionFilePath)..." + $version = Get-VersionFileSemverInternal -VersionFilePath $versionFilePath + + Set-EngineState -Context $shared -Name 'version' -Value $version + Set-EngineFact -Context $shared -Namespace 'release' -Name 'versionFilePath' -Value $versionFilePath -Overwrite Replace -LegacyProperty 'versionFilePath' + Write-Log -Level "OK" -Message " Release version loaded by FileReleaseVersion plugin: $version" +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/src/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 similarity index 71% rename from src/plugins/Platform/GitHub.psm1 rename to utils/plugins/Platform/GitHub.psm1 index 9af816c..61773e0 100644 --- a/src/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -95,10 +95,11 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubTokenEnvVar = $pluginSettings.githubToken + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -108,15 +109,37 @@ function Invoke-Plugin { $releaseDir = $sharedSettings.releaseDir $releaseAssetPaths = @() - Assert-Command gh + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings - if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { - throw "GitHub plugin requires 'githubToken' in scriptSettings.json." + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." } - $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) - if ([string]::IsNullOrWhiteSpace($githubToken)) { - throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($dryRun) { + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" + return + } + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubSecret)) { + throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)." + } + + $ghToken = Get-SecretEnvironmentValue -Name $githubSecret + if ([string]::IsNullOrWhiteSpace($ghToken)) { + throw "GitHub token is not set. Set environment variable '$githubSecret'." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -126,7 +149,26 @@ function Invoke-Plugin { $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version - if ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { + if (Get-Command Get-EngineFact -ErrorAction SilentlyContinue) { + $fromAssets = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -LegacyProperty @('releaseAssetPaths') + if ($null -ne $fromAssets) { + $releaseAssetPaths = @($fromAssets) + } + else { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -eq $packageFile) { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -LegacyProperty @('packageFile') + } + if ($null -ne $packageFile) { + $releaseAssetPaths = @($packageFile.FullName) + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $releaseAssetPaths += $symbolsPackageFile.FullName + } + } + } + } + elseif ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { $releaseAssetPaths = @($sharedSettings.releaseAssetPaths) } elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { @@ -163,7 +205,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " GitHub title: $releaseName" $previousGhToken = $env:GH_TOKEN - $env:GH_TOKEN = $githubToken + $env:GH_TOKEN = $ghToken try { $ghVersion = & gh --version 2>&1 @@ -171,7 +213,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" } - Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + Write-Log -Level "INFO" -Message " Auth secret: $githubSecret" $authArgs = @("api", "repos/$repo", "--jq", ".full_name") $authOutput = & gh @authArgs 2>&1 @@ -188,7 +230,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" @@ -209,6 +251,9 @@ function Invoke-Plugin { $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) try { + if (-not [string]::IsNullOrWhiteSpace($releaseDir) -and -not (Test-Path -LiteralPath $releaseDir -PathType Container)) { + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + } [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( @@ -229,7 +274,7 @@ function Invoke-Plugin { } Write-Log -Level "OK" -Message " GitHub release created successfully." - $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'GitHub' } finally { if ($null -ne $previousGhToken) { @@ -241,4 +286,8 @@ function Invoke-Plugin { } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/PesterTest.psm1 b/utils/plugins/Platform/PesterTest.psm1 new file mode 100644 index 0000000..1dbc1e4 --- /dev/null +++ b/utils/plugins/Platform/PesterTest.psm1 @@ -0,0 +1,238 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Pester test plugin for the RepoUtils test engine. + +.DESCRIPTION + Runs the community Pester suite and publishes normalized coverage metrics on the + shared engine context for QualityGate. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-JaCoCoCoverageMetrics { + param( + [Parameter(Mandatory = $true)] + [string]$ReportPath + ) + + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "JaCoCo coverage report not found at: $ReportPath" + } + } + + [xml]$reportXml = Get-Content -LiteralPath $ReportPath -Raw -Encoding UTF8 + $reportNode = $reportXml.report + if ($null -eq $reportNode) { + return [PSCustomObject]@{ + Success = $false + Error = "Invalid JaCoCo report (missing root ): $ReportPath" + } + } + + function Get-CounterRate { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0.0 + } + + $missed = [long]$counter.missed + $covered = [long]$counter.covered + $total = $missed + $covered + if ($total -le 0) { + return 0.0 + } + + return [math]::Round(($covered / $total) * 100, 1) + } + + function Get-CounterTotal { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.missed + [long]$counter.covered + } + + function Get-CounterCovered { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.covered + } + + return [PSCustomObject]@{ + Success = $true + LineRate = Get-CounterRate -CounterType 'LINE' + BranchRate = Get-CounterRate -CounterType 'BRANCH' + MethodRate = Get-CounterRate -CounterType 'METHOD' + TotalMethods = Get-CounterTotal -CounterType 'METHOD' + CoveredMethods = Get-CounterCovered -CounterType 'METHOD' + CoverageFile = $ReportPath + CoverageFiles = @($ReportPath) + } +} + +function Import-PesterModuleIfNeeded { + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 })) { + Write-Log -Level 'INFO' -Message 'Installing Pester 5...' + Install-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Scope CurrentUser -Force -SkipPublisherCheck + } + + Import-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Force +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Resolve-RelativePaths' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + + if (-not $pluginSettings.testsDir) { + throw "PesterTest plugin requires 'testsDir' in scriptSettings.json." + } + + $testsDirs = @(Resolve-RelativePaths -Value $pluginSettings.testsDir -BasePath $scriptDir) + $testsDir = $testsDirs[0] + + $configFileSetting = 'pester.config.ps1' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.configFile)) { + $configFileSetting = [string]$pluginSettings.configFile + } + + $configPaths = @(Resolve-RelativePaths -Value $configFileSetting -BasePath $scriptDir) + $configPath = $configPaths[0] + + if (-not (Test-Path -LiteralPath $testsDir -PathType Container)) { + throw "Pester tests directory not found at: $testsDir" + } + + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Pester config not found at: $configPath" + } + + Import-PesterModuleIfNeeded + + Write-Log -Level 'STEP' -Message 'Running Pester tests...' + Write-Log -Level 'INFO' -Message " Tests: $testsDir" + Write-Log -Level 'INFO' -Message " Config: $configPath" + + Push-Location $testsDir + try { + $config = & $configPath + if ($null -eq $config) { + throw "Pester config did not return a configuration object: $configPath" + } + + $config.Run.PassThru = $true + $config.Run.Exit = $false + + $resultsDirSetting = [string]$pluginSettings.resultsDir + if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) { + $resultsDirSetting = '..\..\..\test-results' + } + $resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $resultsDirSetting)) + New-Item -ItemType Directory -Path $resultsDirectory -Force | Out-Null + + # Point Pester report outputs at the resolved results dir (overrides pester.config relative paths). + if ($null -ne $config.TestResult -and $config.TestResult.Enabled) { + $config.TestResult.OutputPath = Join-Path $resultsDirectory 'pester-results.xml' + } + if ($null -ne $config.CodeCoverage -and $config.CodeCoverage.Enabled) { + $config.CodeCoverage.OutputPath = Join-Path $resultsDirectory 'coverage.xml' + } + + Write-Log -Level 'INFO' -Message " Results: $resultsDirectory" + + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { + throw "Pester tests failed: $($result.FailedCount) failed, $($result.PassedCount) passed, $($result.SkippedCount) skipped." + } + + $coveragePath = Join-Path $resultsDirectory 'coverage.xml' + $coverageMetrics = $null + if (Test-Path -LiteralPath $coveragePath -PathType Leaf) { + $coverageMetrics = Get-JaCoCoCoverageMetrics -ReportPath $coveragePath + if (-not $coverageMetrics.Success) { + throw $coverageMetrics.Error + } + } + else { + $coverageMetrics = [PSCustomObject]@{ + Success = $true + LineRate = 0.0 + BranchRate = 0.0 + MethodRate = 0.0 + TotalMethods = 0 + CoveredMethods = 0 + CoverageFile = $null + CoverageFiles = @() + } + } + + $testResult = [PSCustomObject]@{ + Success = $true + LineRate = $coverageMetrics.LineRate + BranchRate = $coverageMetrics.BranchRate + MethodRate = $coverageMetrics.MethodRate + TotalMethods = $coverageMetrics.TotalMethods + CoveredMethods = $coverageMetrics.CoveredMethods + CoverageFile = $coverageMetrics.CoverageFile + CoverageFiles = @($coverageMetrics.CoverageFiles) + ResultsDirectory = $resultsDirectory + PesterResult = $result + } + + Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext' + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult + + Write-Log -Level 'OK' -Message " All tests passed! ($($result.PassedCount) passed)" + Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%" + Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%" + Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%" + } + finally { + Pop-Location + } +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata, Get-JaCoCoCoverageMetrics diff --git a/src/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1 similarity index 83% rename from src/plugins/Platform/QualityGate.psm1 rename to utils/plugins/Platform/QualityGate.psm1 index da18d38..a81dfde 100644 --- a/src/plugins/Platform/QualityGate.psm1 +++ b/utils/plugins/Platform/QualityGate.psm1 @@ -10,9 +10,9 @@ shared engine context (same object passed to every plugin as .context). Line coverage for threshold checks is resolved in order (first present wins): - - qualityLineCoverage (generic; any plugin may set this) - - coverageLineRate (conventional flat metric) - - testResult.LineRate (object from a test plugin; property name is conventional) + - facts test.coverageLineRate (via Get-EngineFact) + - legacy qualityLineCoverage / coverageLineRate flat properties + - testResult.LineRate (object from a test plugin) Configure coverageThreshold > 0 to require one of those inputs. With coverageThreshold 0 and scanVulnerabilities false, the plugin is a no-op. @@ -64,6 +64,20 @@ function Get-LineCoveragePercentFromSharedContext { $Shared ) + if (Get-Command Get-EngineFact -ErrorAction SilentlyContinue) { + $fromFact = Get-EngineFact -Context $Shared -Namespace 'test' -Name 'coverageLineRate' -LegacyProperty @('qualityLineCoverage', 'coverageLineRate') + if ($null -ne $fromFact -and -not [string]::IsNullOrWhiteSpace([string]$fromFact)) { + return [double]$fromFact + } + + $testResult = Get-EngineFact -Context $Shared -Namespace 'test' -Name 'testResult' -LegacyProperty @('testResult') + if ($null -ne $testResult -and ($testResult.PSObject.Properties.Name -contains 'LineRate')) { + return [double]$testResult.LineRate + } + + return $null + } + foreach ($prop in @('qualityLineCoverage', 'coverageLineRate')) { if ($Shared.PSObject.Properties.Name -contains $prop) { $raw = $Shared.$prop @@ -92,7 +106,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context @@ -107,11 +121,9 @@ function Invoke-Plugin { if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) { $projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir) } - elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) { - $projectFiles = @($sharedSettings.projectFiles) - } else { - $projectFiles = @() + $fromContext = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles') + $projectFiles = if ($null -ne $fromContext) { @($fromContext) } else { @() } } $coverageThreshold = 0 @@ -129,7 +141,7 @@ function Invoke-Plugin { if ($needCoverageCheck) { $lineRate = Get-LineCoveragePercentFromSharedContext -Shared $sharedSettings if ($null -eq $lineRate) { - throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set one of: qualityLineCoverage, coverageLineRate, or testResult.LineRate (from an earlier plugin)." + throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set test.coverageLineRate (or legacy qualityLineCoverage / coverageLineRate / testResult.LineRate) from an earlier plugin." } Write-Log -Level "STEP" -Message "Checking line coverage threshold against shared context..." diff --git a/src/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 similarity index 90% rename from src/plugins/Platform/ReleasePublishGuard.psm1 rename to utils/plugins/Platform/ReleasePublishGuard.psm1 index adfb262..43b6dfe 100644 --- a/src/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -3,7 +3,7 @@ <# .SYNOPSIS - Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish). + Central gate before remote-mutation plugins (see each plugin's Get-PluginMetadata). .DESCRIPTION Place this plugin immediately before any publish plugins in scriptSettings.json. It sets @@ -50,10 +50,15 @@ function Invoke-NotMetInternal { [string]$Reason ) - $Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $Shared -Name 'skipPublishPlugins' -Value $true + } + else { + $Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force + } + if ($When -eq 'fail') { - Write-Log -Level "ERROR" -Message "ReleasePublishGuard: $Reason" - exit 1 + throw "ReleasePublishGuard: $Reason" } Write-Log -Level "WARN" -Message " Publish suppressed: $Reason" @@ -67,6 +72,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "PluginSupport" -RequiredCommand "Get-PluginBranches" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" @@ -82,7 +88,7 @@ function Invoke-Plugin { throw "ReleasePublishGuard: whenRequirementsNotMet must be 'skip' or 'fail'." } - $shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $false -Force + Set-EngineState -Context $shared -Name 'skipPublishPlugins' -Value $false Write-Log -Level "STEP" -Message "Release publish guard..." @@ -138,7 +144,7 @@ function Invoke-Plugin { return } - $shared | Add-Member -NotePropertyName tag -NotePropertyValue $tag -Force + Set-EngineState -Context $shared -Name 'tag' -Value $tag } $ensureRemote = $true diff --git a/src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 similarity index 100% rename from src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 rename to utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 diff --git a/src/tools/Force-AmendTaggedCommit/scriptSettings.json b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json similarity index 100% rename from src/tools/Force-AmendTaggedCommit/scriptSettings.json rename to utils/tools/Force-AmendTaggedCommit/scriptSettings.json diff --git a/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 similarity index 74% rename from src/tools/Update-RepoUtils/Update-RepoUtils.ps1 rename to utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 index 80082c3..00b3ce0 100644 --- a/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -8,8 +8,7 @@ .DESCRIPTION This script clones the configured repository into a temporary directory, refreshes the parent directory of this script, preserves existing - scriptSettings.json files in subfolders, and copies the cloned source - contents into that parent directory. + scriptSettings.json files in subfolders, and copies the cloned source contents into that parent directory. All configuration is stored in scriptSettings.json. @@ -23,7 +22,9 @@ - repository.sourceSubdirectory: Folder copied into the target directory - repository.preserveFileName: Existing file name to preserve in subfolders - repository.cloneDepth: Depth used for git clone - - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh + - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh (preserve dest) + - repository.omittedRelativeDirectories: Relative directories to delete from dest and never copy from source + (product repos: ["tests"] — RepoUtils self-tests stay only in maksit-repoutils / enterprise) #> [CmdletBinding()] @@ -88,6 +89,56 @@ function Test-IsInRelativeDirectory { return $false } +function Get-ReleaseDeployPreserveFiles { + param( + [Parameter(Mandatory = $true)] + [string]$TargetDirectory + ) + + return @() +} + +function Add-PreservedFileBackup { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IList]$PreservedFiles, + + [Parameter(Mandatory = $true)] + [System.IO.FileInfo]$File, + + [Parameter(Mandatory = $true)] + [string]$TargetDirectory, + + [Parameter(Mandatory = $true)] + [string]$TemporaryRoot, + + [Parameter(Mandatory = $true)] + [bool]$DryRun + ) + + $relativePath = [System.IO.Path]::GetRelativePath($TargetDirectory, $File.FullName) + foreach ($existing in $PreservedFiles) { + if ($existing.RelativePath.Equals($relativePath, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $backupPath = Join-Path $TemporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $PreservedFiles.Add([pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + }) | Out-Null + + if (-not $DryRun) { + $backupDirectory = Split-Path -Parent $backupPath + if (-not (Test-Path -Path $backupDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null + } + + Copy-Item -Path $File.FullName -Destination $backupPath -Force + } +} + #region Import Modules $scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1" @@ -134,6 +185,18 @@ else { [System.IO.Path]::Combine('engines', 'test', 'custom') ) } +[string[]]$omittedRelativeDirectories = if ($settings.repository.omittedRelativeDirectories) { + @( + $settings.repository.omittedRelativeDirectories | + ForEach-Object { + ConvertTo-NormalizedRelativePath -Path ([string]$_) + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) +} +else { + @() +} #endregion @@ -235,32 +298,39 @@ try { } } - $preservedFiles = @() + $preservedFiles = [System.Collections.ArrayList]@() [string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue if ($existingPreservedFiles) { foreach ($file in $existingPreservedFiles) { - $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) - $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) - $preservedFiles += [pscustomobject]@{ - RelativePath = $relativePath - BackupPath = $backupPath - } - - if (-not $dryRun) { - Copy-Item -Path $file.FullName -Destination $backupPath -Force - } + Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun } - Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" + Write-Log -Level "OK" -Message "Preserved $($existingPreservedFiles.Count) existing $preserveFileName file(s)" } else { Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" } + $releaseDeployPreserveFiles = @(Get-ReleaseDeployPreserveFiles -TargetDirectory $targetDirectory) + if ($releaseDeployPreserveFiles.Count -gt 0) { + foreach ($file in $releaseDeployPreserveFiles) { + Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun + } + Write-Log -Level "OK" -Message "Preserved $($releaseDeployPreserveFiles.Count) release deploy file(s)" + } + + $preservedRelativePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($preservedFile in $preservedFiles) { + [void]$preservedRelativePaths.Add($preservedFile.RelativePath) + } + if ($dryRun) { Write-LogStep "Dry run summary" - Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName and release deploy files" Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + if ($omittedRelativeDirectories.Count -gt 0) { + Write-Log -Level "INFO" -Message "Would omit (delete dest + skip copy): $($omittedRelativeDirectories -join ', ')" + } Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" if ($preservedFiles.Count -gt 0) { $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " @@ -275,9 +345,12 @@ try { Where-Object { $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) $_.Name -ne $preserveFileName -and - -not $isInSkippedDirectory + -not $preservedRelativePaths.Contains($relativePath) -and + (-not $isInSkippedDirectory -or $isInOmittedDirectory) } foreach ($file in $filesToRemove) { @@ -289,7 +362,10 @@ try { foreach ($directory in $directoriesToRemove) { $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName) - if (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) { + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) + if ((Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) -and + -not $isInOmittedDirectory) { continue } @@ -305,8 +381,10 @@ try { Where-Object { $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + $isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and + (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories) - -not $isInSkippedDirectory + -not $isInSkippedDirectory -and -not $isInOmittedDirectory } foreach ($sourceFile in $sourceFilesToCopy) { @@ -326,6 +404,9 @@ try { Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" } } + foreach ($omittedDirectory in $omittedRelativeDirectories) { + Write-Log -Level "INFO" -Message "Omitted (not shipped): $omittedDirectory" + } Write-Log -Level "OK" -Message "Source files copied" if ($preservedFiles.Count -gt 0) { @@ -342,7 +423,7 @@ try { Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force } - Write-Log -Level "OK" -Message "$preserveFileName files restored" + Write-Log -Level "OK" -Message "Preserved files restored ($($preservedFiles.Count))" } Write-Log -Level "OK" -Message "========================================" diff --git a/src/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json similarity index 89% rename from src/tools/Update-RepoUtils/scriptSettings.json rename to utils/tools/Update-RepoUtils/scriptSettings.json index 6ad1a7f..ca20f5c 100644 --- a/src/tools/Update-RepoUtils/scriptSettings.json +++ b/utils/tools/Update-RepoUtils/scriptSettings.json @@ -11,6 +11,9 @@ "skippedRelativeDirectories": [ "engines/release/custom", "engines/test/custom" + ], + "omittedRelativeDirectories": [ + "tests" ] } }