diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
new file mode 100644
index 0000000..bd7f494
--- /dev/null
+++ b/.github/workflows/publish-nuget.yml
@@ -0,0 +1,34 @@
+name: Publish to NuGet
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Pack
+ run: |
+ dotnet pack src/MaksIT.HAMode/MaksIT.HAMode.csproj -c Release -o artifacts
+
+ - name: NuGet login (OIDC)
+ id: login
+ uses: NuGet/login@v1
+ with:
+ user: maks-it
+
+ - name: Push to NuGet
+ run: dotnet nuget push artifacts/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0738cf6..cc7941c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +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).
+## [1.0.9] - 2026-10-27
+
+### Changed
+- Updated dependencies: `Google.Protobuf` (`3.36.2`), `MaksIT.Core` (`1.6.14`), `MaksIT.Results` (`2.0.6`), `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` (`10.0.14`), and `StackExchange.Redis` (`3.4.20`). Test stack: `coverlet.MTP` (`10.1.0`), `Microsoft.Extensions.DependencyInjection` / `Logging` (`10.0.14`), and `xunit.v3` (`4.0.2`).
+- Synced RepoUtils: refreshed release engine/plugins (vault support, coverage collect, container probe, package artifact discovery), removed unused Npm plugins, and tightened DotNet publish/pack/version helpers.
+- Added GitHub Actions workflow to pack and publish NuGet on `v*` tags via OIDC (`NuGet/login`).
+
## [1.0.8] - 2026-08-18
### Changed
diff --git a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj
index 58ffeed..d856e99 100644
--- a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj
+++ b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj
@@ -11,10 +11,10 @@
-
-
-
-
+
+
+
+
diff --git a/src/MaksIT.HAMode/MaksIT.HAMode.csproj b/src/MaksIT.HAMode/MaksIT.HAMode.csproj
index b8368fe..50cde2f 100644
--- a/src/MaksIT.HAMode/MaksIT.HAMode.csproj
+++ b/src/MaksIT.HAMode/MaksIT.HAMode.csproj
@@ -8,7 +8,7 @@
$(NoWarn);CS1591
MaksIT.HAMode
- 1.0.8
+ 1.0.9
Maksym Sadovnychyy
MAKS-IT
MaksIT.HAMode
@@ -32,13 +32,13 @@
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1
index b788172..f8d45f5 100644
--- a/utils/engines/release/Invoke-ReleasePackage.ps1
+++ b/utils/engines/release/Invoke-ReleasePackage.ps1
@@ -8,6 +8,10 @@
.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.
+
+ -InstallOnly (Install-Helm-*.bat) keeps the version plugin and HelmSelfDeploy.
+ Compile, test, image build, image push, and HelmPush are skipped. The chart
+ must already be in OCI at the version the version plugin reports.
#>
# Keep a plain param block so unbound launcher arguments remain in $args for
@@ -17,6 +21,17 @@ param()
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
+$installOnly = $false
+$extensionArgs = [System.Collections.Generic.List[object]]::new()
+foreach ($argument in @($args)) {
+ if ([string]$argument -eq '-InstallOnly') {
+ $installOnly = $true
+ continue
+ }
+
+ $extensionArgs.Add($argument)
+}
+
$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1')
@@ -24,7 +39,7 @@ Import-EngineModules -Engine Release
$releaseExtension = $null
if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) {
- $releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList $args
+ $releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList @($extensionArgs)
}
$settings = if ($null -ne $releaseExtension) {
@@ -35,8 +50,29 @@ else {
}
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
+if ($installOnly) {
+ $enabledBefore = @($configuredPlugins | Where-Object { $_.enabled } | ForEach-Object { [string]$_.name })
+ $configuredPlugins = @(Select-HelmInstallPlugins -Plugins $configuredPlugins -ScriptDir $PSScriptRoot)
+ $kept = @($configuredPlugins | ForEach-Object { [string]$_.name })
+ $skipped = @($enabledBefore | Where-Object { $_ -notin $kept })
+ if ($skipped.Count -gt 0) {
+ Write-Log -Level 'INFO' -Message "Install-only: skipping $($skipped -join ', '). Chart and images are taken from OCI."
+ }
-$releaseBanner = if ($null -ne $releaseExtension) {
+ Write-Log -Level 'INFO' -Message "Install-only: running $($kept -join ', ')."
+}
+
+Initialize-RepoUtilsVaultSecrets -Settings $settings -Plugins $configuredPlugins
+
+$releaseBanner = if ($installOnly) {
+ $modeLabel = 'ha'
+ if ($null -ne $releaseExtension -and $releaseExtension.ContextData.PSObject.Properties.Name -contains 'deployMode') {
+ $modeLabel = [string]$releaseExtension.ContextData.deployMode
+ }
+
+ "HELM INSTALL (deploy mode: $modeLabel; OCI chart, no build or push)"
+}
+elseif ($null -ne $releaseExtension) {
$releaseExtension.StepBanner
}
else {
@@ -96,7 +132,11 @@ if (-not $releaseStageInitialized) {
Write-Log -Level 'OK' -Message '=================================================='
if ($releaseHadPluginFailures) {
- Write-Log -Level 'ERROR' -Message 'RELEASE FAILED'
+ $failureLabel = if ($installOnly) { 'HELM INSTALL FAILED' } else { 'RELEASE FAILED' }
+ Write-Log -Level 'ERROR' -Message $failureLabel
+}
+elseif ($installOnly) {
+ Write-Log -Level 'OK' -Message 'HELM INSTALL COMPLETE'
}
elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) {
Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)'
@@ -109,7 +149,7 @@ else {
}
Write-Log -Level 'OK' -Message '=================================================='
-if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
+if (-not $installOnly -and $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."
}
diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json
index 3577558..ee3c69e 100644
--- a/utils/engines/release/scriptSettings.json
+++ b/utils/engines/release/scriptSettings.json
@@ -1,7 +1,9 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
- "description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DotNetNuGet/DockerImagePush/HelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).",
+ "description": "Invoke-ReleasePackage.ps1 plugin settings. Place ReleasePublishGuard before GitHub/DockerImagePush/HelmPush/NpmPublish; use its branches and tag rules. Publish plugins omit per-plugin branches. Semver comes from DotNetReleaseVersion (projectFiles).",
+ "repoUtilsSecretsShared": "RepoUtilsSecretsShared",
+ "repoUtilsSecrets": "RepoUtilsSecrets",
"plugins": [
{
"name": "DotNetReleaseVersion",
@@ -63,48 +65,13 @@
"name": "GitHub",
"stageLabel": "release",
"enabled": true,
- "githubSecret": "GitHub",
+ "githubSecret": "GitClone",
"repository": "https://github.com/MAKS-IT-COM/maksit-hamode",
"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": [
- "@scope/example-contracts",
- "@scope/example-core"
- ]
- },
- {
- "name": "DotNetCleanupArtifacts",
+ "name": "CleanupArtifacts",
"stageLabel": "release",
"enabled": true,
"includePatterns": [
diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1
index feb7afa..211c83a 100644
--- a/utils/modules/ChangelogSupport.psm1
+++ b/utils/modules/ChangelogSupport.psm1
@@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction.
.DESCRIPTION
- Supports only the standard Keep a Changelog version line:
+ Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
## [1.0.0] - 2026-05-24
+ ## [0.1.0-alpha.1] - 2026-08-21
+ ## [0.1.0-beta.1] - 2026-08-21
+ ## [0.1.0-rc.1] - 2026-08-21
#>
+function Get-ChangelogSemverPattern {
+ return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
+}
+
+function Test-ReleaseSemver {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [string]$Version
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ return $false
+ }
+
+ return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
+}
+
+function Test-ReleaseSemverPrerelease {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [string]$Version
+ )
+
+ return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
+}
+
+function Get-ReleaseSemverPrereleaseLabel {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [string]$Version
+ )
+
+ if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
+ return $null
+ }
+
+ if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
+ return $Matches[1].ToLowerInvariant()
+ }
+
+ return 'next'
+}
+
function Get-ChangelogVersionHeaderPattern {
- return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
+ return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-ChangelogNextVersionHeaderPattern {
- return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
+ return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-LatestChangelogVersion {
@@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim()
}
-Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
+Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1
index 3b94fa9..8fceadd 100644
--- a/utils/modules/Engine/EngineContext.psm1
+++ b/utils/modules/Engine/EngineContext.psm1
@@ -321,6 +321,37 @@ function Get-EngineState {
return $null
}
+function Add-ReleaseAssetPaths {
+ param(
+ [Parameter(Mandatory = $true)]
+ [psobject]$Context,
+
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [string[]]$Path
+ )
+
+ $list = [System.Collections.Generic.List[string]]::new()
+ $existing = Get-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Default @() -LegacyProperty @('releaseAssetPaths')
+ foreach ($item in @($existing)) {
+ $value = [string]$item
+ if (-not [string]::IsNullOrWhiteSpace($value) -and -not $list.Contains($value)) {
+ $list.Add($value)
+ }
+ }
+
+ foreach ($item in @($Path)) {
+ $value = [string]$item
+ if ([string]::IsNullOrWhiteSpace($value) -or $list.Contains($value)) {
+ continue
+ }
+
+ $list.Add($value)
+ }
+
+ Set-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Value @($list) -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
+}
+
Export-ModuleMember -Function `
Resolve-RelativePaths, `
Initialize-EngineFactsBag, `
@@ -329,4 +360,5 @@ Export-ModuleMember -Function `
Test-EngineFact, `
Set-EngineState, `
Add-EnginePublishCompletion, `
+ Add-ReleaseAssetPaths, `
Get-EngineState
diff --git a/utils/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1
index a557a58..bc9aae9 100644
--- a/utils/modules/Engine/Import-EngineModules.ps1
+++ b/utils/modules/Engine/Import-EngineModules.ps1
@@ -15,6 +15,7 @@ function Import-EngineModules {
(Join-Path $modulesDir 'ScriptConfig.psm1'),
(Join-Path $modulesDir 'Logging.psm1'),
(Join-Path $engineModuleDir 'PluginSupport.psm1'),
+ (Join-Path $engineModuleDir 'VaultSupport.psm1'),
(Join-Path $engineModuleDir 'EngineContext.psm1')
)
diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1
index f95f58d..0d40360 100644
--- a/utils/modules/Engine/PluginSupport.psm1
+++ b/utils/modules/Engine/PluginSupport.psm1
@@ -33,6 +33,7 @@ function Test-IsEngineRuntimeModuleName {
'TestRunner',
'EngineContext',
'PluginSupport',
+ 'VaultSupport',
'ReleaseSupport',
'TestSupport'
),
@@ -53,7 +54,7 @@ function Get-PluginDependencyGroupDirectories {
}
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
- $preferred = @('Shared', 'Platform', 'DotNet', 'Npm')
+ $preferred = @('Shared', 'Platform', 'DotNet', 'Npm', 'Desktop')
$dirs = [System.Collections.Generic.List[string]]::new()
foreach ($name in $preferred) {
$path = Join-Path $PluginsRoot $name
@@ -293,131 +294,588 @@ function Test-PluginMutatesRemote {
return $false
}
-function Get-SecretEnvironmentValue {
+function Get-RepoUtilsEnvironmentVariable {
<#
.SYNOPSIS
- Reads a secret value from an environment variable by logical name.
+ Reads a named environment variable from Process, then Windows User, then Machine.
.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'
+ Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the
+ current process was started before a User-level pack was set, User/Machine still
+ apply so laptop releases do not require a new shell. An explicit process value —
+ including empty JSON `{}` — shadows User/Machine.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
- return [Environment]::GetEnvironmentVariable($Name)
-}
+ if ([string]::IsNullOrWhiteSpace($Name)) {
+ throw "Environment variable name is required."
+ }
-function Resolve-PluginSecretName {
- <#
- .SYNOPSIS
- Resolves a logical secret name from a plugin's scriptSettings entry.
+ $processValue = [Environment]::GetEnvironmentVariable($Name, 'Process')
+ # Empty string is what SetEnvironmentVariable($null, Process) leaves behind;
+ # treat it as unset so Windows User packs still apply. Explicit '{}' shadows User.
+ if (-not [string]::IsNullOrWhiteSpace($processValue)) {
+ return $processValue
+ }
- .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.
+ foreach ($target in @('User', 'Machine')) {
+ try {
+ $value = [Environment]::GetEnvironmentVariable($Name, $target)
+ }
+ catch {
+ continue
+ }
- .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 $value
}
}
return $null
}
-function Get-RegistryCredentialsFromRuntime {
+function Get-RepoUtilsSecretsEnvNames {
<#
.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 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
+ Reads declared pack environment variable names from scriptSettings / engine context.
#>
- # 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
+ $Settings
)
- $raw = Get-SecretEnvironmentValue -Name $SecretName
- if ([string]::IsNullOrWhiteSpace($raw)) {
- throw "Environment variable '$SecretName' is not set."
+ $sharedName = $null
+ $packName = $null
+ if ($null -ne $Settings) {
+ if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') {
+ $sharedName = [string]$Settings.repoUtilsSecretsShared
+ }
+
+ if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') {
+ $packName = [string]$Settings.repoUtilsSecrets
+ }
+ }
+
+ $sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() }
+ $packName = if ($null -eq $packName) { '' } else { $packName.Trim() }
+ if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) {
+ throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)."
+ }
+
+ return [pscustomobject]@{
+ SharedEnv = $sharedName
+ PackEnv = $packName
+ }
+}
+
+function ConvertFrom-RepoUtilsSecretsPackJson {
+ <#
+ .SYNOPSIS
+ Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws.
+ #>
+ param(
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string]$Raw,
+
+ [Parameter(Mandatory = $true)]
+ [string]$SourceName
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Raw)) {
+ return [pscustomobject]@{}
+ }
+
+ $trimmed = $Raw.Trim()
+ if (-not $trimmed.StartsWith('{')) {
+ throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string."
}
try {
- $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
+ $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
- throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
+ throw "${SourceName} is not valid JSON: $($_.Exception.Message)"
+ }
+
+ if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) {
+ throw "${SourceName} JSON must be an object."
+ }
+
+ return $parsed
+}
+
+function ConvertTo-OrdinalPropertyMap {
+ param($Object)
+
+ $map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
+ if ($null -eq $Object) {
+ return $map
+ }
+
+ if ($Object -is [System.Collections.IDictionary]) {
+ foreach ($key in @($Object.Keys)) {
+ $name = [string]$key
+ if ([string]::IsNullOrWhiteSpace($name)) {
+ continue
+ }
+
+ $map[$name] = $Object[$key]
+ }
+
+ return $map
+ }
+
+ foreach ($property in $Object.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property')) {
+ continue
+ }
+
+ $map[[string]$property.Name] = $property.Value
+ }
+
+ return $map
+}
+
+function ConvertFrom-OrdinalPropertyMap {
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Collections.Generic.Dictionary[string, object]]$Map
+ )
+
+ $properties = [ordered]@{}
+ foreach ($key in $Map.Keys) {
+ $properties[$key] = $Map[$key]
+ }
+
+ return [pscustomobject]$properties
+}
+
+function Merge-RepoUtilsSecretsPackObjects {
+ <#
+ .SYNOPSIS
+ Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry).
+ #>
+ param(
+ $Base,
+ $Overlay
+ )
+
+ $result = ConvertTo-OrdinalPropertyMap -Object $Base
+ $overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay
+ foreach ($key in @($overlayMap.Keys)) {
+ if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
+ throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
+ }
+
+ $overlayValue = $overlayMap[$key]
+ $overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable])
+ if ($overlayIsObject) {
+ $baseCatalog = $null
+ if ($result.ContainsKey($key)) {
+ $baseCatalog = $result[$key]
+ }
+
+ $mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog
+ $overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue
+ foreach ($catalogKey in @($overlayCatalogMap.Keys)) {
+ if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) {
+ throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)."
+ }
+
+ $mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey]
+ }
+
+ $result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog
+ continue
+ }
+
+ $result[$key] = $overlayValue
+ }
+
+ foreach ($key in @($result.Keys)) {
+ if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
+ throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
+ }
+ }
+
+ return ConvertFrom-OrdinalPropertyMap -Map $result
+}
+
+function Get-MergedRepoUtilsSecretsPack {
+ <#
+ .SYNOPSIS
+ Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings).
+ #>
+ param(
+ [Parameter(Mandatory = $false)]
+ $Settings
+ )
+
+ $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
+ $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
+ $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
+ $sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv
+ $packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv
+ return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject
+}
+
+function Get-RepoUtilsSecretSlot {
+ <#
+ .SYNOPSIS
+ Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge.
+ #>
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Name,
+
+ [Parameter(Mandatory = $false)]
+ $Settings,
+
+ [switch]$AllowMissing
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) {
+ throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)."
+ }
+
+ $merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings
+ $match = $null
+ foreach ($property in $merged.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property')) {
+ continue
+ }
+
+ if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) {
+ $match = $property
+ break
+ }
+ }
+
+ if ($null -eq $match) {
+ if ($AllowMissing) {
+ return $null
+ }
+
+ throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack."
+ }
+
+ $value = $match.Value
+ if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) {
+ $text = if ($null -eq $value) { '' } else { [string]$value }
+ if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) {
+ throw "RepoUtilsSecrets slot '$Name' is empty."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($text)) {
+ return $null
+ }
+
+ return $text
+ }
+
+ throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)."
+}
+
+function Copy-RepoUtilsSecretsEnvNamesToContext {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Context,
+
+ [Parameter(Mandatory = $false)]
+ $Settings
+ )
+
+ if ($null -eq $Settings) {
+ return $Context
+ }
+
+ foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) {
+ if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) {
+ $Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force
+ }
+ }
+
+ return $Context
+}
+
+function Test-ContainerRegistryCatalogKey {
+ <#
+ .SYNOPSIS
+ True when Key is PascalCase (Harbor, InCluster), matching env-slot names.
+ #>
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [string]$Key
+ )
+
+ return $Key -cmatch '^[A-Z][A-Za-z0-9]*$'
+}
+
+function Resolve-PluginSecretName {
+ <#
+ .SYNOPSIS
+ Reads a plugin setting that names a RepoUtilsSecrets pack subkey.
+
+ .DESCRIPTION
+ Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret /
+ cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet).
+ They are not environment variable names.
+
+ .PARAMETER PluginSettings
+ Plugin settings object from scriptSettings.json.
+
+ .PARAMETER PropertyName
+ Settings property that holds the pack subkey name (e.g. 'githubSecret').
+
+ .PARAMETER PluginDisplayName
+ Plugin name used in required/validation errors.
+
+ .PARAMETER Required
+ Throw when the property is missing or blank.
+ #>
+ param(
+ [Parameter(Mandatory = $true)]
+ $PluginSettings,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PropertyName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$PluginDisplayName,
+
+ [switch]$Required
+ )
+
+ $display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName }
+ $value = $null
+ if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
+ $raw = [string]$PluginSettings.$PropertyName
+ if (-not [string]::IsNullOrWhiteSpace($raw)) {
+ $value = $raw.Trim()
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($value)) {
+ if ($Required) {
+ throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)."
+ }
+
+ return $null
+ }
+
+ if (-not (Test-ContainerRegistryCatalogKey -Key $value)) {
+ throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)."
+ }
+
+ return $value
+}
+
+function Assert-RetiredContainerRegistrySettingsAbsent {
+ <#
+ .SYNOPSIS
+ Throws when obsolete per-registry secret settings are present.
+ #>
+ param(
+ $Object,
+ [Parameter(Mandatory = $true)]
+ [string]$Context
+ )
+
+ if ($null -eq $Object) {
+ return
+ }
+
+ $retired = @(
+ 'imagesCredentialsSecret',
+ 'additionalImageRegistries',
+ 'additionalImageRegistryUrls',
+ 'additionalImagesCredentialsSecret',
+ 'helmOciCredentialsSecret'
+ )
+
+ foreach ($name in $retired) {
+ $present = $false
+ if ($Object -is [System.Collections.IDictionary]) {
+ $present = $Object.Contains($name)
+ }
+ elseif ($Object.PSObject.Properties.Name -contains $name) {
+ $present = $true
+ }
+
+ if ($present) {
+ throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey."
+ }
+ }
+}
+
+function ConvertFrom-RegistryCredentialPayload {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Payload,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ContextName
+ )
+
+ try {
+ $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim()))
+ }
+ catch {
+ throw "Failed to decode '$ContextName' 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'."
+ throw "Decoded '$ContextName' must be in the form 'username:password'."
}
return @{ User = $parts[0]; Password = $parts[1] }
}
+function Get-ContainerRegistryCatalogObject {
+ <#
+ .SYNOPSIS
+ Validates a PascalCase JSON map of Base64(username:password) values.
+ #>
+ param(
+ [Parameter(Mandatory = $true)]
+ $Catalog,
+
+ [Parameter(Mandatory = $false)]
+ [string]$SourceName = 'ContainerRegistry'
+ )
+
+ if ($Catalog -is [string]) {
+ $raw = [string]$Catalog
+ $trimmed = $raw.Trim()
+ if (-not $trimmed.StartsWith('{')) {
+ throw "$SourceName must be a JSON catalog object { `"Harbor`": `"`", `"InCluster`": `"`" }."
+ }
+
+ try {
+ $Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop
+ }
+ catch {
+ throw "$SourceName is not valid JSON: $($_.Exception.Message)"
+ }
+ }
+
+ if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) {
+ throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)."
+ }
+
+ $keyCount = 0
+ foreach ($property in $Catalog.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property')) {
+ continue
+ }
+
+ $keyCount++
+ $keyName = [string]$property.Name
+ if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) {
+ throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)."
+ }
+ }
+
+ if ($keyCount -eq 0) {
+ throw "$SourceName JSON catalog has no PascalCase keys."
+ }
+
+ return $Catalog
+}
+
+function Get-RegistryCredentialsFromRuntime {
+ <#
+ .SYNOPSIS
+ Loads container-registry username/password from the merged RepoUtilsSecrets pack.
+
+ .DESCRIPTION
+ Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
+ org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
+
+ .PARAMETER Key
+ PascalCase catalog key (e.g. Harbor). Required.
+
+ .PARAMETER Slot
+ PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
+
+ .PARAMETER SharedSettings
+ Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
+
+ .OUTPUTS
+ Hashtable with User and Password keys (decoded credential material).
+ #>
+ param(
+ # Omitted arguments must throw. Mandatory prompts on an interactive host and stalls tests and release runs.
+ [AllowEmptyString()]
+ [string]$Key,
+
+ [AllowEmptyString()]
+ [string]$Slot,
+
+ [psobject]$SharedSettings
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
+ throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($Key)) {
+ throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
+ }
+
+ if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
+ throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
+ }
+
+ $merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings
+ $catalogProperty = $null
+ foreach ($property in $merged.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property')) {
+ continue
+ }
+
+ if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) {
+ $catalogProperty = $property
+ break
+ }
+ }
+
+ if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) {
+ throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack."
+ }
+
+ $catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot
+
+ $match = $null
+ foreach ($property in $catalog.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property')) {
+ continue
+ }
+
+ if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) {
+ $match = $property
+ break
+ }
+ }
+
+ if ($null -eq $match) {
+ throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)."
+ }
+
+ $payload = [string]$match.Value
+ if ([string]::IsNullOrWhiteSpace($payload)) {
+ throw "Catalog key '$Key' in '$Slot' is empty."
+ }
+
+ return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key"
+}
+
function Resolve-EngineDirectoryFromSharedSettings {
param(
[Parameter(Mandatory = $true)]
@@ -596,7 +1054,7 @@ function Resolve-PluginModulePath {
$candidatePaths = [System.Collections.Generic.List[string]]::new()
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
- $preferredGroups = @('Platform', 'DotNet', 'Npm')
+ $preferredGroups = @('Platform', 'DotNet', 'Npm', 'Desktop')
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
@@ -756,4 +1214,4 @@ function Invoke-ConfiguredPlugin {
}
}
-Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
+Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, 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
index 0de88e4..055a9cb 100644
--- a/utils/modules/Engine/ReleaseSupport.psm1
+++ b/utils/modules/Engine/ReleaseSupport.psm1
@@ -213,6 +213,7 @@ function New-EngineContext {
skipPublishPlugins = $false
facts = [ordered]@{}
}
+ $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required)
@@ -265,4 +266,61 @@ function Get-PreferredReleaseBranch {
return "main"
}
-Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch
+function Select-HelmInstallPlugins {
+ <#
+ .SYNOPSIS
+ Keeps the release version plugin and HelmSelfDeploy for an OCI install without build or push.
+
+ .DESCRIPTION
+ Install-Helm-*.bat passes -InstallOnly. The chart and images are already in OCI, so compile,
+ test, image build, image push, and HelmPush are not run. The version plugin still supplies
+ the chart tag (shared.version). HelmSelfDeploy dryRun is rejected: that path needs the local
+ package HelmPush keeps in the same run.
+ #>
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [object[]]$Plugins,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ScriptDir
+ )
+
+ $versionPlugins = @(Get-EnabledVersionPlugins -Plugins $Plugins -ScriptDir $ScriptDir)
+ if ($versionPlugins.Count -eq 0) {
+ throw "Install-only Helm requires exactly one enabled release version plugin (providesVersion) so the OCI chart tag can be resolved. Build and push plugins are not run."
+ }
+
+ if ($versionPlugins.Count -gt 1) {
+ $names = ($versionPlugins | ForEach-Object { [string]$_.name }) -join ', '
+ throw "Install-only Helm requires exactly one enabled release version plugin. Found: $names."
+ }
+
+ $deployPlugins = @($Plugins | Where-Object {
+ $null -ne $_ -and
+ $_.enabled -and
+ [string]$_.name -eq 'HelmSelfDeploy'
+ })
+
+ if ($deployPlugins.Count -eq 0) {
+ throw "Install-only Helm requires an enabled HelmSelfDeploy plugin. The chart is installed from OCI; image build, image push, and HelmPush are not run."
+ }
+
+ if ($deployPlugins.Count -gt 1) {
+ throw "Install-only Helm found multiple enabled HelmSelfDeploy plugins."
+ }
+
+ $deployPlugin = $deployPlugins[0]
+ if ($deployPlugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $deployPlugin.dryRun -and [bool]$deployPlugin.dryRun) {
+ throw "Install-only Helm cannot use HelmSelfDeploy dryRun. That path needs the local package from HelmPush in the same run. Omit dryRun to install the chart already published to OCI."
+ }
+
+ $versionName = [string]$versionPlugins[0].name
+ return @($Plugins | Where-Object {
+ $null -ne $_ -and
+ $_.enabled -and
+ ([string]$_.name -eq $versionName -or [string]$_.name -eq 'HelmSelfDeploy')
+ })
+}
+
+Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch, Select-HelmInstallPlugins
diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1
index b49b60b..da06af2 100644
--- a/utils/modules/Engine/TestSupport.psm1
+++ b/utils/modules/Engine/TestSupport.psm1
@@ -38,6 +38,7 @@ function New-EngineContext {
utilsDir = $SrcDir
facts = [ordered]@{}
}
+ $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) {
diff --git a/utils/modules/Engine/VaultSupport.psm1 b/utils/modules/Engine/VaultSupport.psm1
new file mode 100644
index 0000000..c4226d3
--- /dev/null
+++ b/utils/modules/Engine/VaultSupport.psm1
@@ -0,0 +1,373 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+function Test-RepoUtilsUseVaultEnabled {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Settings
+ )
+
+ if ($null -eq $Settings -or -not ($Settings.PSObject.Properties.Name -contains 'useVault')) {
+ return $false
+ }
+
+ $value = $Settings.useVault
+ if ($value -is [bool]) {
+ return $value
+ }
+
+ return [string]$value -eq 'true'
+}
+
+function ConvertFrom-VaultConnectionSecret {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Raw
+ )
+
+ $trimmed = $Raw.Trim()
+ $separator = $trimmed.IndexOf('|')
+ if ($separator -lt 1 -or $separator -ge ($trimmed.Length - 1)) {
+ throw "Vault connection must be 'baseAddress|apiKey' (pipe separator). Example: http://172.16.0.14|your-key"
+ }
+
+ $baseAddress = $trimmed.Substring(0, $separator).Trim().TrimEnd('/')
+ $apiKey = $trimmed.Substring($separator + 1).Trim()
+ if ([string]::IsNullOrWhiteSpace($baseAddress) -or [string]::IsNullOrWhiteSpace($apiKey)) {
+ throw "Vault connection is missing base address or API key."
+ }
+
+ return [pscustomobject]@{
+ BaseAddress = $baseAddress
+ ApiKey = $apiKey
+ }
+}
+
+function Get-RepoUtilsVaultScope {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Settings
+ )
+
+ $organization = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_ORGANIZATION')
+ if ([string]::IsNullOrWhiteSpace($organization) -and ($Settings.PSObject.Properties.Name -contains 'vaultOrganization')) {
+ $organization = [string]$Settings.vaultOrganization
+ }
+
+ $application = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_APPLICATION')
+ if ([string]::IsNullOrWhiteSpace($application) -and ($Settings.PSObject.Properties.Name -contains 'vaultApplication')) {
+ $application = [string]$Settings.vaultApplication
+ }
+
+ $organization = if ($null -eq $organization) { '' } else { $organization.Trim() }
+ $application = if ($null -eq $application) { '' } else { $application.Trim() }
+
+ if ([string]::IsNullOrWhiteSpace($organization) -or [string]::IsNullOrWhiteSpace($application)) {
+ throw "useVault is true but vault organization/application are not set. Set vaultOrganization/vaultApplication in scriptSettings.json or MAKSIT_VAULT_ORGANIZATION / MAKSIT_VAULT_APPLICATION."
+ }
+
+ return [pscustomobject]@{
+ Organization = $organization
+ Application = $application
+ }
+}
+
+function Get-RepoUtilsVaultConnectionSecretName {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Settings
+ )
+
+ if (($Settings.PSObject.Properties.Name -contains 'vaultConnectionSecret') -and
+ -not [string]::IsNullOrWhiteSpace([string]$Settings.vaultConnectionSecret)) {
+ return ([string]$Settings.vaultConnectionSecret).Trim()
+ }
+
+ return 'MAKSIT_VAULT'
+}
+
+function Get-RepoUtilsVaultOrgPackApplicationName {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Settings
+ )
+
+ $application = $null
+ if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') {
+ $application = [string]$Settings.vaultRepoUtilsApplication
+ }
+
+ $application = if ($null -eq $application) { '' } else { $application.Trim() }
+ if ([string]::IsNullOrWhiteSpace($application)) {
+ throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)."
+ }
+
+ return $application
+}
+
+function Get-VaultNameFilterExpression {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Name
+ )
+
+ $escaped = $Name.Replace('\', '\\').Replace('"', '\"')
+ return "Name == `"$escaped`""
+}
+
+function Import-RepoUtilsVaultClientModule {
+ $modulePath = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_MODULE_PATH')
+ if (-not [string]::IsNullOrWhiteSpace($modulePath)) {
+ Import-Module $modulePath -Force -ErrorAction Stop
+ return
+ }
+
+ if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
+ return
+ }
+
+ Import-Module 'MaksIT.Vault.Client.PowerShell' -ErrorAction SilentlyContinue
+}
+
+function Find-RepoUtilsVaultSecretMatch {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$OrganizationName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApplicationName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$SecretName,
+
+ [Parameter(Mandatory = $true)]
+ [pscustomobject]$Connection,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Cmdlet', 'Rest')]
+ [string]$Mode
+ )
+
+ $orgFilter = Get-VaultNameFilterExpression -Name $OrganizationName
+ $appFilter = Get-VaultNameFilterExpression -Name $ApplicationName
+ $pageNumber = 1
+
+ while ($true) {
+ $items = @()
+ $hasNext = $false
+
+ if ($Mode -eq 'Cmdlet') {
+ $response = Search-VaultSecrets -PageNumber $pageNumber -PageSize 100 `
+ -OrganizationFilters $orgFilter -ApplicationFilters $appFilter
+ if ($null -ne $response -and $null -ne $response.Items) {
+ $items = @($response.Items)
+ }
+ if ($null -ne $response) {
+ $hasNext = [bool]$response.HasNextPage
+ }
+ }
+ else {
+ $body = @{
+ pageNumber = $pageNumber
+ pageSize = 100
+ organizationFilters = $orgFilter
+ applicationFilters = $appFilter
+ } | ConvertTo-Json -Compress
+ $uri = "$($Connection.BaseAddress)/api/vault/secrets"
+ $headers = @{ 'X-API-KEY' = $Connection.ApiKey }
+ $response = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $body
+ if ($null -ne $response.items) {
+ $items = @($response.items)
+ }
+ elseif ($null -ne $response.Items) {
+ $items = @($response.Items)
+ }
+ $hasNext = [bool]($response.hasNextPage)
+ if (-not $hasNext) {
+ $hasNext = [bool]($response.HasNextPage)
+ }
+ }
+
+ foreach ($item in $items) {
+ $itemName = [string]$(if ($item.PSObject.Properties.Name -contains 'Name') { $item.Name } else { $item.name })
+ if ([string]::Equals($itemName, $SecretName, [System.StringComparison]::Ordinal)) {
+ return $item
+ }
+ }
+
+ if (-not $hasNext) {
+ break
+ }
+
+ $pageNumber++
+ }
+
+ return $null
+}
+
+function Get-RepoUtilsVaultSecretValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Match,
+
+ [Parameter(Mandatory = $true)]
+ [pscustomobject]$Connection,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Cmdlet', 'Rest')]
+ [string]$Mode
+ )
+
+ $organizationId = if ($Match.PSObject.Properties.Name -contains 'OrganizationId') { $Match.OrganizationId } else { $Match.organizationId }
+ $applicationId = if ($Match.PSObject.Properties.Name -contains 'ApplicationId') { $Match.ApplicationId } else { $Match.applicationId }
+ $secretId = if ($Match.PSObject.Properties.Name -contains 'Id') { $Match.Id } else { $Match.id }
+
+ if ($Mode -eq 'Cmdlet') {
+ $version = Get-VaultSecret -OrganizationId $organizationId -ApplicationId $applicationId -SecretId $secretId -SecretVersion 'current'
+ if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version.Value)) {
+ return $null
+ }
+ return [string]$version.Value
+ }
+
+ $uri = "$($Connection.BaseAddress)/api/vault/organization/$organizationId/application/$applicationId/secret/$secretId" +
+ '?secretVersion=current'
+ $headers = @{ 'X-API-KEY' = $Connection.ApiKey }
+ $version = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
+ $value = if ($version.PSObject.Properties.Name -contains 'Value') { $version.Value } else { $version.value }
+ if ([string]::IsNullOrWhiteSpace([string]$value)) {
+ return $null
+ }
+ return [string]$value
+}
+
+function Resolve-RepoUtilsVaultSecretValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$OrganizationName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApplicationName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$SecretName,
+
+ [Parameter(Mandatory = $true)]
+ [pscustomobject]$Connection,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Mode
+ )
+
+ $match = Find-RepoUtilsVaultSecretMatch `
+ -OrganizationName $OrganizationName `
+ -ApplicationName $ApplicationName `
+ -SecretName $SecretName `
+ -Connection $Connection `
+ -Mode $Mode
+ if ($null -eq $match) {
+ return $null
+ }
+
+ $value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
+ if ([string]::IsNullOrWhiteSpace($value)) {
+ return $null
+ }
+
+ Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName"
+ return $value
+}
+
+function Initialize-RepoUtilsVaultSecrets {
+ param(
+ [Parameter(Mandatory = $true)]
+ $Settings,
+
+ [Parameter(Mandatory = $true)]
+ $Plugins
+ )
+
+ if (-not (Test-RepoUtilsUseVaultEnabled -Settings $Settings)) {
+ return
+ }
+
+ $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
+ $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
+ $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
+ $sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw)
+ $packPresent = -not [string]::IsNullOrWhiteSpace($packRaw)
+ if ($sharedPresent -and $packPresent) {
+ [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv)
+ [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv)
+ Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch."
+ return
+ }
+
+ $connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings
+ $raw = [Environment]::GetEnvironmentVariable($connectionName)
+ if ([string]::IsNullOrWhiteSpace($raw)) {
+ throw "useVault is true but environment variable '$connectionName' is not set (expected baseAddress|apiKey)."
+ }
+
+ $connection = ConvertFrom-VaultConnectionSecret -Raw $raw
+ $scope = Get-RepoUtilsVaultScope -Settings $Settings
+ $orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings
+
+ Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)"
+
+ $mode = 'Rest'
+ try {
+ Import-RepoUtilsVaultClientModule
+ if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
+ Connect-Vault -BaseAddress $connection.BaseAddress -ApiKey $connection.ApiKey
+ $mode = 'Cmdlet'
+ Write-Log -Level 'INFO' -Message 'Connected to Vault with MaksIT.Vault.Client.PowerShell'
+ }
+ else {
+ Write-Log -Level 'INFO' -Message 'MaksIT.Vault.Client.PowerShell not found; using Vault HTTP API'
+ }
+ }
+ catch {
+ Write-Log -Level 'INFO' -Message "Vault PowerShell module not loaded ($($_.Exception.Message)); using Vault HTTP API"
+ $mode = 'Rest'
+ }
+
+ if (-not $sharedPresent) {
+ $sharedValue = Resolve-RepoUtilsVaultSecretValue `
+ -OrganizationName $scope.Organization `
+ -ApplicationName $orgPackApplication `
+ -SecretName $names.SharedEnv `
+ -Connection $connection `
+ -Mode $mode
+ if ([string]::IsNullOrWhiteSpace($sharedValue)) {
+ $sharedValue = '{}'
+ Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack."
+ }
+
+ [Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process')
+ }
+
+ if (-not $packPresent) {
+ $packValue = Resolve-RepoUtilsVaultSecretValue `
+ -OrganizationName $scope.Organization `
+ -ApplicationName $scope.Application `
+ -SecretName $names.PackEnv `
+ -Connection $connection `
+ -Mode $mode
+ if ([string]::IsNullOrWhiteSpace($packValue)) {
+ $packValue = '{}'
+ Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack."
+ }
+
+ [Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process')
+ }
+}
+
+Export-ModuleMember -Function @(
+ 'Test-RepoUtilsUseVaultEnabled',
+ 'ConvertFrom-VaultConnectionSecret',
+ 'Get-RepoUtilsVaultScope',
+ 'Get-RepoUtilsVaultConnectionSecretName',
+ 'Get-RepoUtilsVaultOrgPackApplicationName',
+ 'Initialize-RepoUtilsVaultSecrets'
+)
diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1
index 6c86899..5574f02 100644
--- a/utils/modules/ExternalCommandSupport.psm1
+++ b/utils/modules/ExternalCommandSupport.psm1
@@ -27,6 +27,10 @@ function Clear-ExternalCommandTestHandler {
$script:ExternalCommandTestHandler = $null
}
+function Test-ExternalCommandTestHandler {
+ return ($null -ne $script:ExternalCommandTestHandler)
+}
+
function Set-ExternalCommandAvailability {
param(
[Parameter(Mandatory = $true)]
@@ -107,7 +111,7 @@ function Invoke-ExternalCommand {
if ($ThrowOnError -and $exitCode -ne 0) {
$preview = ($output | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
- } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' '
+ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12) -join ' '
if ([string]::IsNullOrWhiteSpace($preview)) {
throw "External command '$Name' failed with exit code $exitCode."
}
@@ -128,4 +132,5 @@ Export-ModuleMember -Function `
Invoke-ExternalCommand, `
Set-ExternalCommandTestHandler, `
Clear-ExternalCommandTestHandler, `
+ Test-ExternalCommandTestHandler, `
Set-ExternalCommandAvailability
diff --git a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1
deleted file mode 100644
index bbc7459..0000000
--- a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1
+++ /dev/null
@@ -1,122 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- .NET artifact cleanup plugin — remove NuGet build outputs after release.
-
-.DESCRIPTION
- Removes files from the configured artifacts directory using glob patterns.
- Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the
- end of the Release stage after DotNetCreateArchive 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 "DotNetCleanupArtifacts 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/utils/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1
index e921189..4fe962d 100644
--- a/utils/plugins/DotNet/DotNetCreateArchive.psm1
+++ b/utils/plugins/DotNet/DotNetCreateArchive.psm1
@@ -9,6 +9,10 @@
This plugin compresses .NET release artifact inputs prepared by an earlier
DotNet plugin (DotNetPack or DotNetPublish) into a zip file
and exposes the resulting release assets for later publisher plugins.
+
+ For desktop apps with per-RID publish folders, the zip is the portable
+ build only (default win-x64). Windows installer and Flatpak artifacts are
+ separate GitHub assets and are never added to this zip.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@@ -26,7 +30,7 @@ function Invoke-Plugin {
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
- Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
+ Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
@@ -49,6 +53,34 @@ function Invoke-Plugin {
}
}
+ $portableRid = $null
+ if ($pluginSettings.PSObject.Properties['portableRuntimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.portableRuntimeIdentifier)) {
+ $portableRid = ([string]$pluginSettings.portableRuntimeIdentifier).Trim()
+ }
+
+ $publishOutputs = @(Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Default @())
+ if ($publishOutputs.Count -gt 0) {
+ if ([string]::IsNullOrWhiteSpace($portableRid)) {
+ $portableRid = 'win-x64'
+ }
+
+ $portableOutputs = @(
+ $publishOutputs |
+ Where-Object { [string]$_.runtimeIdentifier -eq $portableRid }
+ )
+ if ($portableOutputs.Count -eq 0) {
+ $portableOutputs = @(
+ $publishOutputs |
+ Where-Object { ([string]$_.runtimeIdentifier).StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase) }
+ )
+ }
+
+ if ($portableOutputs.Count -gt 0) {
+ $archiveInputs = @($portableOutputs | ForEach-Object { [string]$_.directory })
+ Write-Log -Level "INFO" -Message " Portable zip uses runtime '$portableRid' only (installer/Flatpak stay out of the zip)."
+ }
+ }
+
if ($archiveInputs.Count -eq 0) {
throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first."
}
@@ -84,19 +116,20 @@ function Invoke-Plugin {
Write-Log -Level "OK" -Message " Release archive ready: $zipPath"
- $releaseAssetPaths = @($zipPath)
+ $newAssets = [System.Collections.Generic.List[string]]::new()
+ $newAssets.Add($zipPath)
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
if ($null -ne $packageFile) {
- $releaseAssetPaths += $packageFile.FullName
+ $newAssets.Add($packageFile.FullName)
}
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
if ($null -ne $symbolsPackageFile) {
- $releaseAssetPaths += $symbolsPackageFile.FullName
+ $newAssets.Add($symbolsPackageFile.FullName)
}
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'
+ Add-ReleaseAssetPaths -Context $sharedSettings -Path @($newAssets)
}
Export-ModuleMember -Function Invoke-Plugin
diff --git a/utils/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1
index ef08a81..514244a 100644
--- a/utils/plugins/DotNet/DotNetNuGet.psm1
+++ b/utils/plugins/DotNet/DotNetNuGet.psm1
@@ -29,10 +29,10 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$sharedSettings = $Settings.context
- $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
+ $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required
Assert-Command dotnet
@@ -52,13 +52,9 @@ function Invoke-Plugin {
return
}
- if ([string]::IsNullOrWhiteSpace($nugetSecret)) {
- throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
- }
-
- $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
+ $nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($nugetKey)) {
- throw "NuGet API key is not set. Set environment variable '$nugetSecret'."
+ throw "NuGet API key is not set. Set RepoUtilsSecrets slot '$nugetSecret' (nugetSecret)."
}
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {
diff --git a/utils/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1
index d2dacba..649b303 100644
--- a/utils/plugins/DotNet/DotNetPublish.psm1
+++ b/utils/plugins/DotNet/DotNetPublish.psm1
@@ -6,9 +6,14 @@
.NET publish plugin for producing application release artifacts.
.DESCRIPTION
- This plugin publishes the configured .NET project into a release output
- directory and exposes that published directory to the shared release
- context so later release-stage plugins can archive and publish it.
+ This plugin publishes configured .NET projects into the artifacts directory
+ and appends those publish folders to shared archive inputs so later plugins
+ can zip them next to any earlier pack outputs. Existing NuGet package facts
+ (packageFile) are left unchanged.
+
+ Optional runtimeIdentifiers (or runtimeIdentifier) produce per-RID folders
+ under artifactsDir// for desktop installers (Windows MSI /
+ Linux Flatpak). When RIDs are set, selfContained defaults to true.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@@ -19,6 +24,28 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
}
}
+function Get-DotNetPublishRuntimeIdentifiers {
+ param(
+ [Parameter(Mandatory = $true)]
+ $PluginSettings
+ )
+
+ $rids = [System.Collections.Generic.List[string]]::new()
+ if ($PluginSettings.PSObject.Properties['runtimeIdentifiers'] -and $null -ne $PluginSettings.runtimeIdentifiers) {
+ foreach ($item in @($PluginSettings.runtimeIdentifiers)) {
+ $value = [string]$item
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ $rids.Add($value.Trim())
+ }
+ }
+ }
+ elseif ($PluginSettings.PSObject.Properties['runtimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$PluginSettings.runtimeIdentifier)) {
+ $rids.Add(([string]$PluginSettings.runtimeIdentifier).Trim())
+ }
+
+ return @($rids)
+}
+
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
@@ -28,48 +55,145 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
+ Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
+ $pluginSettings = $Settings
$sharedSettings = $Settings.context
- $projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
- $artifactsDirectory = $sharedSettings.artifactsDirectory
- $publishProjectPath = $null
+ $scriptDir = $sharedSettings.scriptDir
+ $projectFiles = @()
Assert-Command dotnet
- if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
- throw "DotNetPublish plugin requires project files in the shared context."
+ if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
+ $projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
+ }
+ else {
+ $fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
+ if ($null -ne $fromFact) {
+ $projectFiles = @($fromFact)
+ }
+ elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
+ $projectFiles = @($sharedSettings.projectFiles)
+ }
}
- $projectFiles = @($projectFiles)
+ if ($projectFiles.Count -eq 0) {
+ throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
+ }
+
+ if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
+ $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
+ Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
+ Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
+ }
+ else {
+ $artifactsDirectory = $sharedSettings.artifactsDirectory
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
+ throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
+ }
if (!(Test-Path $artifactsDirectory)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
- # The first configured project remains the canonical release artifact source.
- $publishProjectPath = $projectFiles[0]
- $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
-
- if (Test-Path $publishDir) {
- Remove-Item -Path $publishDir -Recurse -Force
+ $existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
+ $archiveInputs = [System.Collections.Generic.List[object]]::new()
+ if ($null -ne $existing) {
+ foreach ($item in @($existing)) {
+ if ($null -ne $item) {
+ $archiveInputs.Add($item)
+ }
+ }
}
- Write-Log -Level "STEP" -Message "Publishing release artifact..."
- dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
- if ($LASTEXITCODE -ne 0) {
- throw "dotnet publish failed for $publishProjectPath."
+ $runtimeIdentifiers = @(Get-DotNetPublishRuntimeIdentifiers -PluginSettings $pluginSettings)
+ $selfContained = $null
+ if ($pluginSettings.PSObject.Properties['selfContained'] -and $null -ne $pluginSettings.selfContained) {
+ $selfContained = [bool]$pluginSettings.selfContained
+ }
+ elseif ($runtimeIdentifiers.Count -gt 0) {
+ $selfContained = $true
}
- $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
- if ($publishedItems.Count -eq 0) {
- throw "dotnet publish completed, but no files were produced in: $publishDir"
+ $publishSingleFile = $false
+ if ($pluginSettings.PSObject.Properties['publishSingleFile'] -and $null -ne $pluginSettings.publishSingleFile) {
+ $publishSingleFile = [bool]$pluginSettings.publishSingleFile
}
- Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
+ $additionalArguments = @()
+ if ($pluginSettings.PSObject.Properties['additionalPublishArguments'] -and $null -ne $pluginSettings.additionalPublishArguments) {
+ $additionalArguments = @($pluginSettings.additionalPublishArguments | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ }
- 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'
+ $publishOutputs = [System.Collections.Generic.List[object]]::new()
+ $ridLoop = @($null)
+ if ($runtimeIdentifiers.Count -gt 0) {
+ $ridLoop = @($runtimeIdentifiers)
+ }
+
+ foreach ($publishProjectPath in $projectFiles) {
+ $projectName = [System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)
+ $projectRoot = Join-Path $artifactsDirectory $projectName
+
+ foreach ($runtimeIdentifier in $ridLoop) {
+ $publishDir = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
+ $projectRoot
+ }
+ else {
+ Join-Path $projectRoot ([string]$runtimeIdentifier)
+ }
+
+ if (Test-Path $publishDir) {
+ Remove-Item -Path $publishDir -Recurse -Force
+ }
+
+ $ridLabel = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) { 'current runtime' } else { [string]$runtimeIdentifier }
+ Write-Log -Level "STEP" -Message "Publishing release artifact ($projectName / $ridLabel)..."
+ $dotnetPublishArguments = @(
+ 'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo'
+ )
+ if (-not [string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
+ $dotnetPublishArguments += @('-r', [string]$runtimeIdentifier)
+ }
+
+ if ($null -ne $selfContained) {
+ if ($selfContained) {
+ $dotnetPublishArguments += '--self-contained'
+ }
+ else {
+ $dotnetPublishArguments += '--no-self-contained'
+ }
+ }
+
+ if ($publishSingleFile) {
+ $dotnetPublishArguments += '-p:PublishSingleFile=true'
+ }
+
+ if ($additionalArguments.Count -gt 0) {
+ $dotnetPublishArguments += $additionalArguments
+ }
+
+ Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetPublishArguments | Out-Null
+
+ $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
+ if ($publishedItems.Count -eq 0) {
+ throw "dotnet publish completed, but no files were produced in: $publishDir"
+ }
+
+ Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
+ $archiveInputs.Add($publishDir)
+ $publishOutputs.Add([pscustomobject]@{
+ projectName = $projectName
+ runtimeIdentifier = [string]$runtimeIdentifier
+ directory = $publishDir
+ })
+ }
+ }
+
+ Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
+ Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @($publishOutputs) -Overwrite Replace
}
-Export-ModuleMember -Function Invoke-Plugin
+Export-ModuleMember -Function Invoke-Plugin, Get-DotNetPublishRuntimeIdentifiers
diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1
index 3cb01fd..98a857f 100644
--- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1
+++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1
@@ -7,9 +7,11 @@
.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.
+ projectFiles .csproj, or from the nearest Directory.Build.props when the
+ csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
+ version plus the resolved projectFiles (csproj paths for later pack/publish)
+ 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)) {
@@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
}
}
+function ConvertTo-MsbuildPropertyStringInternal {
+ param(
+ $Value
+ )
+
+ if ($null -eq $Value) {
+ return $null
+ }
+
+ if ($Value -is [System.Xml.XmlElement]) {
+ return [string]$Value.InnerText
+ }
+
+ return [string]$Value
+}
+
function Get-CsprojPropertyValueInternal {
param(
[Parameter(Mandatory = $true)]
@@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1
if ($propNode) {
- return $propNode.$PropertyName
+ return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
+ }
+
+ return $null
+}
+
+function Get-DirectoryBuildPropsVersionInternal {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$ProjectPath
+ )
+
+ # MSBuild uses the first Directory.Build.props found walking up from the project directory.
+ $dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
+ while (-not [string]::IsNullOrWhiteSpace($dir)) {
+ $propsPath = Join-Path $dir 'Directory.Build.props'
+ if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
+ [xml]$props = Get-Content -LiteralPath $propsPath
+ return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
+ }
+
+ $parent = [System.IO.Directory]::GetParent($dir)
+ if ($null -eq $parent) {
+ break
+ }
+
+ $dir = $parent.FullName
}
return $null
@@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
[xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
-
- if ([string]::IsNullOrWhiteSpace([string]$version)) {
- throw "DotNetReleaseVersion: not found in '$ProjectPath'."
+ if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
+ return [string]$version
}
- return [string]$version
+ $version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
+ if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
+ return [string]$version
+ }
+
+ throw "DotNetReleaseVersion: not found in '$ProjectPath' or a parent Directory.Build.props."
}
function Get-PluginMetadata {
@@ -87,6 +135,10 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
+ Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
+ if (-not (Test-ReleaseSemver -Version $version)) {
+ throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
+ }
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
diff --git a/utils/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1
deleted file mode 100644
index 7c43f52..0000000
--- a/utils/plugins/Npm/NpmBuild.psm1
+++ /dev/null
@@ -1,89 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- Builds an npm workspace (install + build script).
-
-.DESCRIPTION
- Runs npm ci (or npm install when useCi is false) and npm run build in the
- configured workspace root. Requires NpmReleaseVersion to have set
- shared npmWorkspaceRoot unless workspaceRoot is configured explicitly.
-#>
-
-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 "Resolve-RelativePaths"
-
- $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 "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
- }
-
- $useCi = $true
- if ($null -ne $pluginSettings.useCi) {
- $useCi = [bool]$pluginSettings.useCi
- }
-
- $buildScript = 'build'
- if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) {
- $buildScript = [string]$pluginSettings.buildScript
- }
-
- Push-Location $workspaceRoot
- try {
- if ($useCi) {
- Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..."
- npm ci
- if ($LASTEXITCODE -ne 0) {
- throw "npm ci failed with exit code $LASTEXITCODE."
- }
- }
- else {
- Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..."
- npm install
- if ($LASTEXITCODE -ne 0) {
- throw "npm install failed with exit code $LASTEXITCODE."
- }
- }
-
- Write-Log -Level "STEP" -Message "Running npm run $buildScript..."
- npm run $buildScript
- if ($LASTEXITCODE -ne 0) {
- throw "npm run $buildScript failed with exit code $LASTEXITCODE."
- }
-
- Write-Log -Level "OK" -Message " npm build completed."
- }
- finally {
- Pop-Location
- }
-}
-
-Export-ModuleMember -Function Invoke-Plugin
diff --git a/utils/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1
deleted file mode 100644
index e89f604..0000000
--- a/utils/plugins/Npm/NpmJestTest.psm1
+++ /dev/null
@@ -1,75 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- npm/Jest test plugin for the test engine.
-
-.DESCRIPTION
- Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
- normalized metrics on the shared engine context 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 "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
- Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
- Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
-
- $pluginSettings = $Settings
- $sharedSettings = $Settings.context
- $scriptDir = $sharedSettings.scriptDir
-
- Assert-Command npm
-
- if (-not $pluginSettings.workspaceRoot) {
- throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json."
- }
-
- $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
- $workspaceRoot = $workspaceRoots[0]
-
- $testScript = 'test'
- if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
- $testScript = [string]$pluginSettings.testScript
- }
-
- $coverageDirectory = 'coverage'
- if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
- $coverageDirectory = [string]$pluginSettings.coverageDirectory
- }
-
- $testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
-
- if (-not $testResult.Success) {
- throw "Tests failed. $($testResult.Error)"
- }
-
- 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) {
- Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile'
- }
-
- Write-Log -Level "OK" -Message " All tests 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)%"
-}
-
-Export-ModuleMember -Function Invoke-Plugin
diff --git a/utils/plugins/Npm/NpmPack.psm1 b/utils/plugins/Npm/NpmPack.psm1
deleted file mode 100644
index 7ec9945..0000000
--- a/utils/plugins/Npm/NpmPack.psm1
+++ /dev/null
@@ -1,139 +0,0 @@
-#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
deleted file mode 100644
index 4b3a903..0000000
--- a/utils/plugins/Npm/NpmPackageSupport.psm1
+++ /dev/null
@@ -1,62 +0,0 @@
-#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/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1
deleted file mode 100644
index db6b0ec..0000000
--- a/utils/plugins/Npm/NpmPublish.psm1
+++ /dev/null
@@ -1,145 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- Publishes npm workspace packages to the npm registry.
-
-.DESCRIPTION
- 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)) {
- $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 "Resolve-RelativePaths"
-
- $pluginSettings = $Settings
- $shared = $Settings.context
-
- $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared
-
- 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 "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
- }
-
- $registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
- 'https://registry.npmjs.org'
- }
- else {
- [string]$pluginSettings.registry
- }
-
- $access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
- 'public'
- }
- else {
- [string]$pluginSettings.access
- }
-
- $publishOrder = @()
- if ($pluginSettings.publishOrder) {
- if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
- $publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
- }
- elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
- $publishOrder = @([string]$pluginSettings.publishOrder)
- }
- }
-
- if ($publishOrder.Count -eq 0) {
- 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=$npmToken
-"@
-
- Push-Location $workspaceRoot
- try {
- Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
-
- foreach ($packageName in $publishOrder) {
- Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
- if ($useWorkspaces) {
- npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
- }
- else {
- Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
- npm publish --access $access --userconfig $tempNpmRcPath
- }
-
- if ($LASTEXITCODE -ne 0) {
- throw "Failed to publish npm package '$packageName'."
- }
- Write-Log -Level "OK" -Message " Published $packageName."
- }
-
- Write-Log -Level "OK" -Message " npm publish completed."
- Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
- Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
- }
- finally {
- if (Test-Path $tempNpmRcPath -PathType Leaf) {
- Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
- }
- Pop-Location
- }
-}
-
-function Get-PluginMetadata {
- [pscustomobject]@{ mutatesRemote = $true }
-}
-
-Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1
deleted file mode 100644
index ad48e46..0000000
--- a/utils/plugins/Npm/NpmReleaseVersion.psm1
+++ /dev/null
@@ -1,104 +0,0 @@
-#requires -Version 7.0
-#requires -PSEdition Core
-
-<#
-.SYNOPSIS
- Loads npm workspace release version into shared context.
-
-.DESCRIPTION
- Reads semver from the configured workspace package.json and writes it to
- shared context version. Optionally synchronizes version fields across
- workspace package manifests before build/publish.
-#>
-
-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-PackageJsonVersionInternal {
- param(
- [Parameter(Mandatory = $true)]
- [string]$PackageJsonPath
- )
-
- if (-not (Test-Path $PackageJsonPath -PathType Leaf)) {
- throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'."
- }
-
- $json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
- $version = [string]$json.version
- if ([string]::IsNullOrWhiteSpace($version)) {
- throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
- }
-
- if ($version -notmatch '^\d+\.\d+\.\d+') {
- throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
- }
-
- return $version
-}
-
-function Set-PackageJsonVersionInternal {
- param(
- [Parameter(Mandatory = $true)]
- [string]$PackageJsonPath,
-
- [Parameter(Mandatory = $true)]
- [string]$Version
- )
-
- $raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8
- $json = $raw | ConvertFrom-Json
- $json.version = $Version
- ($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)]
- $Settings
- )
-
- Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
- Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
-
- $pluginSettings = $Settings
- $shared = $Settings.context
-
- $packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
- if ($packageJsonPaths.Count -eq 0) {
- throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json."
- }
- $packageJsonPath = $packageJsonPaths[0]
-
- $version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
- $syncWorkspaceVersions = $false
- if ($null -ne $pluginSettings.syncWorkspaceVersions) {
- $syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions
- }
-
- if ($syncWorkspaceVersions) {
- $workspaceRoot = Split-Path -Parent $packageJsonPath
- $packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue
- foreach ($manifest in $packageManifests) {
- Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version
- }
- Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version."
- }
-
- $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, Get-PluginMetadata
diff --git a/utils/plugins/Platform/CollectCoverage.psm1 b/utils/plugins/Platform/CollectCoverage.psm1
new file mode 100644
index 0000000..a7d959c
--- /dev/null
+++ b/utils/plugins/Platform/CollectCoverage.psm1
@@ -0,0 +1,78 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.SYNOPSIS
+ Collects coverage metrics from an existing test results directory.
+
+.DESCRIPTION
+ Stack-agnostic parser for recovered container or host test output. Auto-detects .NET
+ Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the
+ same shared-context keys as DotNetTest / NpmJestTest for QualityGate.
+ Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image.
+#>
+
+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 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory'
+ Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext'
+
+ $pluginSettings = $Settings
+ $sharedSettings = $Settings.context
+ $scriptDir = $sharedSettings.scriptDir
+
+ $resultsDirSetting = $pluginSettings.resultsDir
+ if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) {
+ if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) {
+ $resultsDirSetting = $sharedSettings.testResultsDirectory
+ }
+ else {
+ throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)."
+ }
+ }
+
+ $resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) {
+ [string]$resultsDirSetting
+ }
+ else {
+ [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting)))
+ }
+
+ $format = 'auto'
+ if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) {
+ $format = [string]$pluginSettings.format
+ }
+
+ Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..."
+ $testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true
+ if (-not $testResult.Success) {
+ throw $testResult.Error
+ }
+
+ Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
+
+ $formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' }
+ Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)."
+ 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)%"
+}
+
+function Get-PluginMetadata {
+ [pscustomobject]@{ mutatesRemote = $false }
+}
+
+Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
diff --git a/utils/plugins/Platform/ContainerEngineProbe.psm1 b/utils/plugins/Platform/ContainerEngineProbe.psm1
new file mode 100644
index 0000000..896501e
--- /dev/null
+++ b/utils/plugins/Platform/ContainerEngineProbe.psm1
@@ -0,0 +1,52 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.SYNOPSIS
+ Probes docker/podman availability and sets mandatory shared engine state for the pipeline.
+
+.DESCRIPTION
+ Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine,
+ dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context.
+ Fails the pipeline when no usable engine is found.
+#>
+
+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 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState'
+
+ $shared = $Settings.context
+
+ Write-Log -Level 'STEP' -Message 'Probing container engine availability...'
+ $state = Resolve-ActiveContainerEngineState
+ Set-ContainerEngineSharedState -SharedSettings $shared -State $state
+
+ $display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared)
+ Write-Log -Level 'OK' -Message " Active engine: $display"
+ Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)"
+ if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) {
+ Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).'
+ }
+ else {
+ Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')"
+ }
+}
+
+function Get-PluginMetadata {
+ [pscustomobject]@{ mutatesRemote = $false }
+}
+
+Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
diff --git a/utils/plugins/Platform/DiscoverPackageArtifacts.psm1 b/utils/plugins/Platform/DiscoverPackageArtifacts.psm1
new file mode 100644
index 0000000..15ec38f
--- /dev/null
+++ b/utils/plugins/Platform/DiscoverPackageArtifacts.psm1
@@ -0,0 +1,124 @@
+#requires -Version 7.0
+#requires -PSEdition Core
+
+<#
+.SYNOPSIS
+ Discovers package artifacts produced by pack plugins or container build pipelines.
+
+.DESCRIPTION
+ Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files
+ and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs).
+#>
+
+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'
+
+ $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)))
+ if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
+ Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
+ Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
+ }
+ else {
+ $sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
+ }
+ }
+ else {
+ $artifactsDirectory = $sharedSettings.artifactsDirectory
+ }
+
+ if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
+ throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.'
+ }
+
+ $nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object {
+ $_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg'
+ })
+ $tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue)
+
+ if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) {
+ throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository."
+ }
+
+ if ($nupkgCandidates.Count -gt 0) {
+ if ([string]::IsNullOrWhiteSpace([string]$version)) {
+ throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.'
+ }
+
+ 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."
+ }
+
+ $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
+ $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
+ if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
+ 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'
+ Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
+ }
+ return
+ }
+
+ if ($tgzCandidates.Count -gt 0) {
+ Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts'
+
+ Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..."
+ $resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
+
+ foreach ($tarball in $resolved.PackageFiles) {
+ Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)"
+ }
+
+ $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force
+ $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
+ if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
+ Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
+ Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
+ Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
+ if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
+ Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
+ }
+ }
+ return
+ }
+
+ throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory"
+}
+
+function Get-PluginMetadata {
+ [pscustomobject]@{ mutatesRemote = $false }
+}
+
+Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata
diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1
index c7bb3bd..6095289 100644
--- a/utils/plugins/Platform/FileReleaseVersion.psm1
+++ b/utils/plugins/Platform/FileReleaseVersion.psm1
@@ -7,9 +7,10 @@
.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.
+ repo-root VERSION), including optional prerelease (0.1.0-alpha.1). 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)) {
@@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
}
$version = $version -replace '^[vV]', ''
- if ($version -notmatch '^\d+\.\d+\.\d+') {
- throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
+ Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
+ if (-not (Test-ReleaseSemver -Version $version)) {
+ throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
return $version
@@ -55,6 +57,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
+ Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$shared = $Settings.context
$versionFileSetting = if ($Settings.versionFilePath) {
diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1
index 61773e0..89be3fc 100644
--- a/utils/plugins/Platform/GitHub.psm1
+++ b/utils/plugins/Platform/GitHub.psm1
@@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
- (see ChangelogSupport.psm1).
+ (including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
+ see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@@ -95,11 +96,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 "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
- $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret'
$configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
@@ -110,6 +111,7 @@ function Invoke-Plugin {
$releaseAssetPaths = @()
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
+ $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json."
@@ -128,18 +130,17 @@ function Invoke-Plugin {
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
+ if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
+ Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
+ }
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
+ $ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings
if ([string]::IsNullOrWhiteSpace($ghToken)) {
- throw "GitHub token is not set. Set environment variable '$githubSecret'."
+ throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)."
}
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
@@ -230,7 +231,7 @@ function Invoke-Plugin {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
}
- throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository."
+ throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository."
}
Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"
@@ -261,6 +262,10 @@ function Invoke-Plugin {
"--title", $releaseName,
"--notes-file", $notesFilePath
)
+ if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
+ $createReleaseArgs += '--prerelease'
+ }
+
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {
diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1
index 43b6dfe..56153f6 100644
--- a/utils/plugins/Platform/ReleasePublishGuard.psm1
+++ b/utils/plugins/Platform/ReleasePublishGuard.psm1
@@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
- Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
- tag version vs DotNetReleaseVersion, optional push tag to remote.
+ Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
+ (vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
+ tag version vs release version, optional push tag to remote.
The engine preflight no longer reads git tags; this plugin sets context.tag from the
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
@@ -77,6 +78,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
+ Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings
$shared = $Settings.context
@@ -123,8 +125,9 @@ function Invoke-Plugin {
return
}
- if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
- Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
+ $tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
+ if ($tag -notmatch $tagPattern) {
+ Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
return
}