diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..1b51ae2 --- /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@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Pack + run: | + dotnet pack src/MaksIT.Core/MaksIT.Core.csproj -c Release -o artifacts + + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: maks-it.com + + - 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 54257cc..5aef0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.11] - 2026-10-26 + +### Changed +- Updated NuGet dependencies: Microsoft.Extensions.* and System.Threading.RateLimiting 10.0.11 → 10.0.14, Microsoft.AspNetCore.* 2.3.12 → 2.3.13, Microsoft.AspNetCore.Cryptography.KeyDerivation 10.0.11 → 10.0.14, Microsoft.IdentityModel.Tokens and System.IdentityModel.Tokens.Jwt 8.22.0 → 8.23.0, System.Linq.Dynamic.Core 1.7.3 → 1.7.4, System.CommandLine 2.0.11 → 2.0.14, coverlet.MTP 10.0.1 → 10.1.0, xunit.v3 4.0.0 → 4.0.2 +- Synced RepoUtils utils and pointed CONTRIBUTING at the maksit-repoutils Origin URL + ## [1.6.10] - 2026-08-21 ### Added @@ -109,3 +115,4 @@ Template for new releases: ### Security - Security improvements --> + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45b6366..2d0b54f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,7 +115,7 @@ This project follows [Semantic Versioning](https://semver.org/): ## Release Process -Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)). +Orchestration lives in **`utils/`** (from [maksit-repoutils](https://git.maks-it.com/MAKS-IT/maksit-repoutils)). ### Prerequisites diff --git a/README.md b/README.md index 4162a16..e6cf0ac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MaksIT.Core Library Documentation ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-38.5%25-yellow) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.4%25-yellow) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.8%25-yellow) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-37.9%25-yellow) **MaksIT.Core** is a .NET 10 library of shared helpers used across MaksIT products: domain/DTO/Web API bases, strongly-typed enumerations, extensions, logging, security (JWT, JWK, JWS, TOTP, AES-GCM), sagas, COMB GUIDs, and Web API pagination. diff --git a/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj b/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj index 777a9dd..ad6787b 100644 --- a/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj +++ b/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj b/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj index 5b42409..13f611b 100644 --- a/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj +++ b/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj @@ -8,7 +8,7 @@ $(MSBuildProjectName.Replace(" ", "_")) false - 1.6.10 + 1.6.11 Maksym Sadovnychyy MAKS-IT MaksIT.Core.Cli @@ -21,7 +21,7 @@ - + diff --git a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj index 9619251..3875e17 100644 --- a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj +++ b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj @@ -12,9 +12,9 @@ - - - + + + diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index 6dd8568..ca694e7 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -12,7 +12,7 @@ MaksIT.Core - 1.6.10 + 1.6.11 Maksym Sadovnychyy MAKS-IT MaksIT.Core @@ -42,16 +42,16 @@ - - - - - - - - - - + + + + + + + + + + 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 f0cc6fa..837baf5 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -1,7 +1,10 @@ { "$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).", + "vaultRepoUtilsApplication": "Shared", + "repoUtilsSecretsShared": "RepoUtilsSecretsShared", + "repoUtilsSecrets": "RepoUtilsSecrets", "plugins": [ { "name": "DotNetReleaseVersion", @@ -73,48 +76,13 @@ "name": "GitHub", "stageLabel": "release", "enabled": true, - "githubSecret": "GitHub", + "githubSecret": "GitClone", "repository": "https://github.com/MAKS-IT-COM/maksit-core", "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/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 cf9a090..649b303 100644 --- a/utils/plugins/DotNet/DotNetPublish.psm1 +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -10,6 +10,10 @@ 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)) { @@ -20,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)] @@ -29,6 +55,7 @@ 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 @@ -81,32 +108,92 @@ function Invoke-Plugin { } } + $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 + } + + $publishSingleFile = $false + if ($pluginSettings.PSObject.Properties['publishSingleFile'] -and $null -ne $pluginSettings.publishSingleFile) { + $publishSingleFile = [bool]$pluginSettings.publishSingleFile + } + + $additionalArguments = @() + if ($pluginSettings.PSObject.Properties['additionalPublishArguments'] -and $null -ne $pluginSettings.additionalPublishArguments) { + $additionalArguments = @($pluginSettings.additionalPublishArguments | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + $publishOutputs = [System.Collections.Generic.List[object]]::new() + $ridLoop = @($null) + if ($runtimeIdentifiers.Count -gt 0) { + $ridLoop = @($runtimeIdentifiers) + } + foreach ($publishProjectPath in $projectFiles) { - $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)) + $projectName = [System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath) + $projectRoot = Join-Path $artifactsDirectory $projectName - if (Test-Path $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force + 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 + }) } - - Write-Log -Level "STEP" -Message "Publishing release artifact..." - $dotnetPublishArguments = @( - 'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo' - ) - & dotnet @dotnetPublishArguments - if ($LASTEXITCODE -ne 0) { - throw "dotnet publish failed for $publishProjectPath." - } - - $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) } 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/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 7357ffe..0000000 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ /dev/null @@ -1,162 +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" - Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel" - - $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 - } - - $npmDistTag = $null - if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) { - $npmDistTag = [string]$pluginSettings.npmDistTag - } - else { - $npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version) - } - - $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) { - $tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag } - Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)" - } - 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'..." - $publishArgs = @('publish') - if ($useWorkspaces) { - $publishArgs += @('-w', $packageName) - } - else { - Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName - } - $publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath) - if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) { - $publishArgs += @('--tag', $npmDistTag) - Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)." - } - - npm @publishArgs - - 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 31f300a..0000000 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ /dev/null @@ -1,106 +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'." - } - - Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" - if (-not (Test-ReleaseSemver -Version $version)) { - throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)." - } - - 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" - Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" - - $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/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 5528635..89be3fc 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -101,7 +101,6 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -112,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." @@ -138,13 +138,9 @@ function Invoke-Plugin { 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)) { @@ -235,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)"