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