diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index 468bb33..ee69f16 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,4 +1,4 @@ - + Branch Coverage: 50% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index cfc17a2..64fd339 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,4 +1,4 @@ - + Line Coverage: 62.1% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index ecd0309..2ae5b4f 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,4 +1,4 @@ - + Method Coverage: 60% diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat index a2c4bda..20029f8 100644 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat @@ -1,3 +1,3 @@ @echo off -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" -pause \ No newline at end of file +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" +pause diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 index bca2946..d338a36 100644 --- a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS Amends the latest commit, recreates its associated tag, and force pushes both to remote. @@ -16,10 +19,10 @@ If specified, shows what would be done without making changes. .EXAMPLE - .\Force-AmendTaggedCommit.ps1 + pwsh -File .\Force-AmendTaggedCommit.ps1 .EXAMPLE - .\Force-AmendTaggedCommit.ps1 -DryRun + pwsh -File .\Force-AmendTaggedCommit.ps1 -DryRun .NOTES CONFIGURATION (scriptsettings.json): diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat index 4569dab..2790074 100644 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat @@ -1,3 +1,3 @@ @echo off -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" pause diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 index 5c4bdde..24f7d09 100644 --- a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 @@ -1,11 +1,13 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS - Runs tests, collects coverage, and generates SVG badges for README. + Generates SVG coverage badges for README. .DESCRIPTION This script runs unit tests via TestRunner.psm1, then generates shields.io-style SVG badges for line, branch, and method coverage. - Optional HTML report generation is controlled by scriptsettings.json (openReport). Configuration is stored in scriptsettings.json: - openReport : Generate and open full HTML report (true/false) @@ -21,7 +23,7 @@ dotnet tool install -g dotnet-reportgenerator-globaltool .EXAMPLE - .\Generate-CoverageBadges.ps1 + pwsh -File .\Generate-CoverageBadges.ps1 Runs tests and generates coverage badges (and optionally HTML report if configured). .OUTPUTS @@ -186,7 +188,7 @@ foreach ($badge in $Settings.badges) { $color = Get-BadgeColor $metricValue $svg = New-Badge -label $badge.label -value "$metricValue%" -color $color $path = Join-Path $BadgesDir $badge.name - $svg | Out-File -FilePath $path -Encoding utf8 + $svg | Out-File -FilePath $path -Encoding utf8NoBOM Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%" } diff --git a/utils/GitTools.psm1 b/utils/GitTools.psm1 index 5b795c9..405f408 100644 --- a/utils/GitTools.psm1 +++ b/utils/GitTools.psm1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + # # Shared Git helpers for utility scripts. # diff --git a/utils/Logging.psm1 b/utils/Logging.psm1 index 28be784..a0cbb3d 100644 --- a/utils/Logging.psm1 +++ b/utils/Logging.psm1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + function Get-LogTimestampInternal { return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") } diff --git a/utils/Release-NuGetPackage/Release-NuGetPackage.bat b/utils/Release-NuGetPackage/Release-NuGetPackage.bat deleted file mode 100644 index 7fa08e9..0000000 --- a/utils/Release-NuGetPackage/Release-NuGetPackage.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1" -pause \ No newline at end of file diff --git a/utils/Release-NuGetPackage/scriptsettings.json b/utils/Release-NuGetPackage/scriptsettings.json deleted file mode 100644 index 27ad9bc..0000000 --- a/utils/Release-NuGetPackage/scriptsettings.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Release NuGet Package Script Settings", - "description": "Configuration file for Release-NuGetPackage.ps1 script.", - - "github": { - "enabled": true, - "githubToken": "GITHUB_MAKS_IT_COM", - "repository": "https://github.com/MAKS-IT-COM/maksit-dapr" - }, - - "nuget": { - "enabled": true, - "nugetApiKey": "NUGET_MAKS_IT", - "source": "https://api.nuget.org/v3/index.json" - }, - - "branches": { - "release": "main", - "dev": "dev" - }, - - "paths": { - "csprojPaths": [ - "..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" - ], - "testResultsDir": "..\\..\\testResults", - "releaseDir": "..\\..\\release", - "changelogPath": "..\\..\\CHANGELOG.md", - "testProject": "..\\..\\src\\MaksIT.Dapr.Tests" - }, - - "release": { - "zipNamePattern": "maksit.dapr-{version}.zip", - "releaseTitlePattern": "Release {version}" - }, - - "_comments": { - "github": { - "enabled": "Enable/disable GitHub release creation.", - "githubToken": "Environment variable name containing GitHub token used by gh CLI.", - "repository": "GitHub repository override used for releases (supports owner/repo or full GitHub URL)." - }, - "nuget": { - "enabled": "Enable/disable NuGet publish step.", - "nugetApiKey": "Environment variable name containing NuGet API key.", - "source": "NuGet feed URL passed to dotnet nuget push." - }, - "branches": { - "release": "Branch that requires tag and allows full publish flow.", - "dev": "Branch for local/dev build flow (no tag required)." - }, - "paths": { - "csprojPaths": "List of project files used for version discovery and publish output.", - "testResultsDir": "Directory where test artifacts are written.", - "releaseDir": "Output directory for release archives and artifacts.", - "changelogPath": "Path to CHANGELOG.md used for version and release notes extraction.", - "testProject": "Test project path used by TestRunner." - }, - "release": { - "zipNamePattern": "Archive name pattern. Supports {version} placeholder.", - "releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder." - } - } -} diff --git a/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 b/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 new file mode 100644 index 0000000..43dc044 --- /dev/null +++ b/utils/Release-Package/CorePlugins/CleanupArtifacts.psm1 @@ -0,0 +1,121 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Cleanup plugin for removing generated artifacts after pipeline completion. + +.DESCRIPTION + This plugin removes files from the configured artifacts directory using + glob patterns. It is typically placed at the end of the Release stage so + cleanup becomes explicit and opt-in per repository. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-CleanupPatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @('*.nupkg', '*.snupkg') + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @('*.nupkg', '*.snupkg') + } + + return @([string]$ConfiguredPatterns) +} + +function Get-ExcludePatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @() + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @() + } + + return @([string]$ConfiguredPatterns) +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns + $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CleanupArtifacts plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" + return + } + + Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." + + $itemsToRemove = @() + foreach ($pattern in $patterns) { + $matchedItems = @( + Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } + ) + + if ($excludePatterns.Count -gt 0) { + $matchedItems = @( + $matchedItems | + Where-Object { + $item = $_ + -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) + } + ) + } + + $itemsToRemove += @($matchedItems) + } + + $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) + + if ($itemsToRemove.Count -eq 0) { + Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." + return + } + + foreach ($item in $itemsToRemove) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Removed: $($item.Name)" + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/CreateArchive.psm1 b/utils/Release-Package/CorePlugins/CreateArchive.psm1 new file mode 100644 index 0000000..54cce44 --- /dev/null +++ b/utils/Release-Package/CorePlugins/CreateArchive.psm1 @@ -0,0 +1,93 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Creates a release zip from prepared build artifacts. + +.DESCRIPTION + This plugin compresses the release artifact inputs prepared by an earlier + producer plugin (for example DotNetPack or DotNetPublish) into a zip file + and exposes the resulting release assets for later publisher plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $version = $sharedSettings.Version + $archiveInputs = @() + + if ($sharedSettings.PSObject.Properties['ReleaseArchiveInputs'] -and $sharedSettings.ReleaseArchiveInputs) { + $archiveInputs = @($sharedSettings.ReleaseArchiveInputs) + } + elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $archiveInputs = @($sharedSettings.PackageFile.FullName) + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $archiveInputs += $sharedSettings.SymbolsPackageFile.FullName + } + } + + if ($archiveInputs.Count -eq 0) { + throw "CreateArchive plugin requires prepared artifacts. Run a producer plugin (for example DotNetPack or DotNetPublish) first." + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CreateArchive plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $zipNamePattern = if ($pluginSettings.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.zipNamePattern)) { + [string]$pluginSettings.zipNamePattern + } + else { + "release-{version}.zip" + } + + $zipFileName = $zipNamePattern -replace '\{version\}', $version + $zipPath = Join-Path $artifactsDirectory $zipFileName + + if (Test-Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + + Write-Log -Level "STEP" -Message "Creating release archive..." + Compress-Archive -Path $archiveInputs -DestinationPath $zipPath -CompressionLevel Optimal -Force + + if (-not (Test-Path $zipPath -PathType Leaf)) { + throw "Failed to create release archive at: $zipPath" + } + + Write-Log -Level "OK" -Message " Release archive ready: $zipPath" + + $releaseAssetPaths = @($zipPath) + if ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $releaseAssetPaths += $sharedSettings.PackageFile.FullName + } + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName + } + + $sharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $artifactsDirectory -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchivePath -NotePropertyValue $zipPath -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetPack.psm1 b/utils/Release-Package/CorePlugins/DotNetPack.psm1 new file mode 100644 index 0000000..8353217 --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetPack.psm1 @@ -0,0 +1,99 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET pack plugin for producing package artifacts. + +.DESCRIPTION + This plugin creates package output for the release pipeline. + It packs the configured .NET project, resolves the generated + package artifacts, and publishes them into shared runtime context + for later plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + # Load this globally only as a fallback. Re-importing PluginSupport in its own execution path + # can invalidate commands already resolved by the release engine. + 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" + + $sharedSettings = $Settings.Context + $projectFiles = $sharedSettings.ProjectFiles + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $version = $sharedSettings.Version + $packageProjectPath = $null + $releaseArchiveInputs = @() + + Assert-Command dotnet + + if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) { + throw "DotNetPack plugin requires project files in the shared context." + } + + $outputDir = $artifactsDirectory + + if (!(Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir | Out-Null + } + + # The release context guarantees ProjectFiles is an array, so index 0 is the first project path, + # not the first character of a string. + $packageProjectPath = $projectFiles[0] + Write-Log -Level "STEP" -Message "Packing NuGet package..." + dotnet pack $packageProjectPath -c Release -o $outputDir --nologo ` + -p:IncludeSymbols=true ` + -p:SymbolPackageFormat=snupkg + if ($LASTEXITCODE -ne 0) { + throw "dotnet pack failed for $packageProjectPath." + } + + # dotnet pack can leave older packages in the artifacts directory. + # Pick the newest file matching the current version rather than assuming a clean folder. + $packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" | + Where-Object { + $_.Name -like "*$version*.nupkg" -and + $_.Name -notlike "*.symbols.nupkg" -and + $_.Name -notlike "*.snupkg" + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $packageFile) { + throw "Could not locate generated NuGet package for version $version in: $outputDir" + } + + Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = Get-ChildItem -Path $outputDir -Filter "*.snupkg" | + Where-Object { $_.Name -like "*$version*.snupkg" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($symbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)" + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + else { + Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." + } + + $sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $packageFile -Force + $sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetPublish.psm1 b/utils/Release-Package/CorePlugins/DotNetPublish.psm1 new file mode 100644 index 0000000..8acb8bc --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetPublish.psm1 @@ -0,0 +1,71 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .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. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $sharedSettings = $Settings.Context + $projectFiles = $sharedSettings.ProjectFiles + $artifactsDirectory = $sharedSettings.ArtifactsDirectory + $publishProjectPath = $null + + Assert-Command dotnet + + if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) { + throw "DotNetPublish plugin requires project files in the 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 + } + + 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." + } + + $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" + + $sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue @($publishDir) -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/DotNetTest.psm1 b/utils/Release-Package/CorePlugins/DotNetTest.psm1 new file mode 100644 index 0000000..7759fc0 --- /dev/null +++ b/utils/Release-Package/CorePlugins/DotNetTest.psm1 @@ -0,0 +1,72 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET test plugin for executing automated tests. + +.DESCRIPTION + This plugin resolves the configured .NET test project and optional + results directory, runs tests through TestRunner, and stores + the resulting test metrics in shared runtime context. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + # Same fallback pattern as the other plugins: use the existing shared module if it is already loaded. + 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-TestsWithCoverage" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $testProjectSetting = $pluginSettings.project + $testResultsDirSetting = $pluginSettings.resultsDir + $scriptDir = $sharedSettings.ScriptDir + + if ([string]::IsNullOrWhiteSpace($testProjectSetting)) { + throw "DotNetTest plugin requires 'project' in scriptsettings.json." + } + + $testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testProjectSetting)) + $testResultsDir = $null + if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) { + $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting)) + } + + Write-Log -Level "STEP" -Message "Running tests..." + + # Build a splatted hashtable so optional arguments can be added without duplicating the call site. + $invokeTestParams = @{ + TestProjectPath = $testProjectPath + Silent = $true + } + if ($testResultsDir) { + $invokeTestParams.ResultsDirectory = $testResultsDir + } + + $testResult = Invoke-TestsWithCoverage @invokeTestParams + + if (-not $testResult.Success) { + throw "Tests failed. $($testResult.Error)" + } + + $sharedSettings | Add-Member -NotePropertyName TestResult -NotePropertyValue $testResult -Force + + 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/Release-Package/CorePlugins/GitHub.psm1 b/utils/Release-Package/CorePlugins/GitHub.psm1 new file mode 100644 index 0000000..38a9386 --- /dev/null +++ b/utils/Release-Package/CorePlugins/GitHub.psm1 @@ -0,0 +1,232 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + GitHub release plugin. + +.DESCRIPTION + This plugin validates GitHub CLI access, resolves the target + repository, and creates the configured GitHub release using the + shared release artifacts and extracted release notes. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-GitHubRepositoryInternal { + param( + [Parameter(Mandatory = $false)] + [string]$ConfiguredRepository + ) + + $repoSource = $ConfiguredRepository + + if ([string]::IsNullOrWhiteSpace($repoSource)) { + $repoSource = git config --get remote.origin.url + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoSource)) { + throw "Could not determine git remote origin URL." + } + } + + $repoSource = $repoSource.Trim() + + if ($repoSource -match "(?i)github\.com[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + if ($repoSource -match "^(?[^/]+)/(?[^/]+)$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + throw "Could not parse GitHub repo from source: $repoSource. Configure Plugins[].repository with 'owner/repo' or a GitHub URL." +} + +function Get-ReleaseNotesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesFile, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-Log -Level "INFO" -Message "Verifying release notes source..." + if (-not (Test-Path $ReleaseNotesFile -PathType Leaf)) { + throw "Release notes source file not found at: $ReleaseNotesFile" + } + + $releaseNotesContent = Get-Content $ReleaseNotesFile -Raw + if ($releaseNotesContent -notmatch '##\s+v(\d+\.\d+\.\d+)') { + throw "No version entry found in the configured release notes source." + } + + $releaseNotesVersion = $Matches[1] + if ($releaseNotesVersion -ne $Version) { + throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)." + } + + Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion" + + Write-Log -Level "STEP" -Message "Extracting release notes..." + $pattern = "(?ms)^##\s+v$([regex]::Escape($Version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)" + $match = [regex]::Match($releaseNotesContent, $pattern) + + if (-not $match.Success) { + throw "Release notes entry for version $Version not found." + } + + Write-Log -Level "OK" -Message " Release notes extracted." + return $match.Value.Trim() +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $githubTokenEnvVar = $pluginSettings.githubToken + $configuredRepository = $pluginSettings.repository + $releaseNotesFileSetting = $pluginSettings.releaseNotesFile + $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern + $scriptDir = $sharedSettings.ScriptDir + $version = $sharedSettings.Version + $tag = $sharedSettings.Tag + $releaseDir = $sharedSettings.ReleaseDir + $releaseAssetPaths = @() + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { + throw "GitHub plugin requires 'githubToken' in scriptsettings.json." + } + + $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) + if ([string]::IsNullOrWhiteSpace($githubToken)) { + throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + } + + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptsettings.json." + } + + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($sharedSettings.PSObject.Properties['ReleaseAssetPaths'] -and $sharedSettings.ReleaseAssetPaths) { + $releaseAssetPaths = @($sharedSettings.ReleaseAssetPaths) + } + elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) { + $releaseAssetPaths = @($sharedSettings.PackageFile.FullName) + if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName + } + } + + if ($releaseAssetPaths.Count -eq 0) { + throw "GitHub release requires at least one prepared release asset." + } + + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + + Write-Log -Level "INFO" -Message " GitHub repository: $repo" + Write-Log -Level "INFO" -Message " GitHub tag: $tag" + Write-Log -Level "INFO" -Message " GitHub title: $releaseName" + + $previousGhToken = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + + try { + $ghVersion = & gh --version 2>&1 + if ($ghVersion) { + Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" + } + + Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + + $authArgs = @("api", "repos/$repo", "--jq", ".full_name") + $authOutput = & gh @authArgs 2>&1 + $authExitCode = $LASTEXITCODE + + if ($authExitCode -ne 0 -or [string]::IsNullOrWhiteSpace(($authOutput | Out-String))) { + Write-Log -Level "WARN" -Message " gh auth check failed (exit code: $authExitCode)." + if ($authOutput) { + $authOutput | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + } + + $authStatus = & gh auth status --hostname github.com 2>&1 + if ($authStatus) { + $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + } + + throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + } + + Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" + Write-Log -Level "STEP" -Message "Creating GitHub release..." + + $releaseViewArgs = @("release", "view", $tag, "--repo", $repo) + & gh @releaseViewArgs 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." + $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") + & gh @releaseDeleteArgs + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete existing release $tag." + } + } + + $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) + + try { + [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) + + $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( + "--repo", $repo, + "--title", $releaseName, + "--notes-file", $notesFilePath + ) + & gh @createReleaseArgs + + if ($LASTEXITCODE -ne 0) { + throw "Failed to create GitHub release for tag $tag." + } + } + finally { + if (Test-Path $notesFilePath) { + Remove-Item $notesFilePath -Force + } + } + + Write-Log -Level "OK" -Message " GitHub release created successfully." + $sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force + } + finally { + if ($null -ne $previousGhToken) { + $env:GH_TOKEN = $previousGhToken + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/NuGet.psm1 b/utils/Release-Package/CorePlugins/NuGet.psm1 new file mode 100644 index 0000000..4dafc54 --- /dev/null +++ b/utils/Release-Package/CorePlugins/NuGet.psm1 @@ -0,0 +1,67 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + NuGet publish plugin. + +.DESCRIPTION + This plugin publishes the package artifact from shared runtime + context to the configured NuGet feed using the configured API key. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $packageFile = $sharedSettings.PackageFile + + Assert-Command dotnet + + if (-not $packageFile) { + throw "NuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running NuGet." + } + + if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { + throw "NuGet plugin requires 'nugetApiKey' in scriptsettings.json." + } + + $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) + if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { + throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + } + + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "STEP" -Message "Pushing to NuGet.org..." + dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + + if ($LASTEXITCODE -ne 0) { + throw "Failed to push the package to NuGet." + } + + Write-Log -Level "OK" -Message " NuGet push completed." + $sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CorePlugins/QualityGate.psm1 b/utils/Release-Package/CorePlugins/QualityGate.psm1 new file mode 100644 index 0000000..450a468 --- /dev/null +++ b/utils/Release-Package/CorePlugins/QualityGate.psm1 @@ -0,0 +1,119 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Quality gate plugin for validating release readiness. + +.DESCRIPTION + This plugin evaluates quality constraints using shared test + results and project files. It enforces coverage thresholds + and checks for vulnerable packages before release plugins run. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Test-VulnerablePackagesInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + $findings = @() + + foreach ($projectPath in $ProjectFiles) { + Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))" + + $output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet list package --vulnerable failed for $projectPath." + } + + $outputText = ($output | Out-String) + if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") { + $findings += [pscustomobject]@{ + Project = $projectPath + Output = $outputText.Trim() + } + } + } + + return $findings +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + + $pluginSettings = $Settings + $sharedSettings = $Settings.Context + $coverageThresholdSetting = $pluginSettings.coverageThreshold + $failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities + $projectFiles = $sharedSettings.ProjectFiles + $testResult = $null + if ($sharedSettings.PSObject.Properties['TestResult']) { + $testResult = $sharedSettings.TestResult + } + + if ($null -eq $testResult) { + throw "QualityGate plugin requires test results. Run the DotNetTest plugin first." + } + + $coverageThreshold = 0 + if ($null -ne $coverageThresholdSetting) { + $coverageThreshold = [double]$coverageThresholdSetting + } + + if ($coverageThreshold -gt 0) { + Write-Log -Level "STEP" -Message "Checking coverage threshold..." + if ([double]$testResult.LineRate -lt $coverageThreshold) { + throw "Line coverage $($testResult.LineRate)% is below the configured threshold of $coverageThreshold%." + } + + Write-Log -Level "OK" -Message " Coverage threshold met: $($testResult.LineRate)% >= $coverageThreshold%" + } + else { + Write-Log -Level "WARN" -Message "Skipping coverage threshold check (disabled)." + } + + Assert-Command dotnet + + $failOnVulnerabilities = $true + if ($null -ne $failOnVulnerabilitiesSetting) { + $failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting + } + + $vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles + + if ($vulnerabilities.Count -eq 0) { + Write-Log -Level "OK" -Message " No vulnerable packages detected." + return + } + + foreach ($finding in $vulnerabilities) { + Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))" + $finding.Output -split "`r?`n" | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_)) { + Write-Log -Level "WARN" -Message " $_" + } + } + } + + if ($failOnVulnerabilities) { + throw "Vulnerable packages were detected and failOnVulnerabilities is enabled." + } + + Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/Release-Package/CustomPlugins/.gitkeep b/utils/Release-Package/CustomPlugins/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/Release-Package/CustomPlugins/.gitkeep @@ -0,0 +1 @@ + diff --git a/utils/Release-Package/DotNetProjectSupport.psm1 b/utils/Release-Package/DotNetProjectSupport.psm1 new file mode 100644 index 0000000..a510eb5 --- /dev/null +++ b/utils/Release-Package/DotNetProjectSupport.psm1 @@ -0,0 +1,110 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-PluginPathListSetting -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +function Get-DotNetProjectPropertyValue { + param( + [Parameter(Mandatory = $true)] + [xml]$Csproj, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + # SDK-style .csproj files can have multiple PropertyGroup nodes. + # Use the first group that defines the requested property. + $propNode = $Csproj.Project.PropertyGroup | + Where-Object { $_.$PropertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$PropertyName + } + + return $null +} + +function Get-DotNetProjectVersions { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + Write-Log -Level "INFO" -Message "Reading version(s) from .NET project files..." + $projectVersions = @{} + + foreach ($projectPath in $ProjectFiles) { + if (-not (Test-Path $projectPath -PathType Leaf)) { + Write-Error "Project file not found at: $projectPath" + exit 1 + } + + if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") { + Write-Error "Configured project file is not a .csproj file: $projectPath" + exit 1 + } + + [xml]$csproj = Get-Content $projectPath + $version = Get-DotNetProjectPropertyValue -Csproj $csproj -PropertyName "Version" + + if (-not $version) { + Write-Error "Version not found in $projectPath" + exit 1 + } + + $projectVersions[$projectPath] = $version + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version" + } + + return $projectVersions +} + +function New-DotNetReleaseContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + # The array wrapper is intentional: without it, one configured project can collapse to a string, + # and later indexing [0] would return only the first character of the path. + $projectFiles = @(Get-PluginPathListSetting -Plugins $Plugins -PropertyName "projectFiles" -BasePath $ScriptDir) + $artifactsDirectory = Get-PluginPathSetting -Plugins $Plugins -PropertyName "artifactsDir" -BasePath $ScriptDir + + if ($projectFiles.Count -eq 0) { + Write-Error "No .NET project files configured in plugin settings. Add 'projectFiles' to a relevant plugin." + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + Write-Error "No artifacts directory configured in plugin settings. Add 'artifactsDir' to a relevant plugin." + exit 1 + } + + $projectVersions = Get-DotNetProjectVersions -ProjectFiles $projectFiles + # The first configured project is treated as the canonical version source for the release. + $version = $projectVersions[$projectFiles[0]] + + return [pscustomobject]@{ + ProjectFiles = $projectFiles + ArtifactsDirectory = $artifactsDirectory + Version = $version + } +} + +Export-ModuleMember -Function Get-DotNetProjectPropertyValue, Get-DotNetProjectVersions, New-DotNetReleaseContext diff --git a/utils/Release-Package/EngineSupport.psm1 b/utils/Release-Package/EngineSupport.psm1 new file mode 100644 index 0000000..c3d29f2 --- /dev/null +++ b/utils/Release-Package/EngineSupport.psm1 @@ -0,0 +1,165 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) { + $gitToolsModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "GitTools.psm1" + if (Test-Path $gitToolsModulePath -PathType Leaf) { + Import-Module $gitToolsModulePath -Force + } +} + +if (-not (Get-Command Get-PluginStage -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +if (-not (Get-Command New-DotNetReleaseContext -ErrorAction SilentlyContinue)) { + $dotNetProjectSupportModulePath = Join-Path $PSScriptRoot "DotNetProjectSupport.psm1" + if (Test-Path $dotNetProjectSupportModulePath -PathType Leaf) { + Import-Module $dotNetProjectSupportModulePath -Force + } +} + +function Assert-WorkingTreeClean { + param( + [Parameter(Mandatory = $true)] + [bool]$IsReleaseBranch + ) + + $gitStatus = Get-GitStatusShort + if ($gitStatus) { + if ($IsReleaseBranch) { + Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." + Write-Log -Level "WARN" -Message "Uncommitted files:" + $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + exit 1 + } + + Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)." + return + } + + Write-Log -Level "OK" -Message " Working directory is clean." +} + +function Initialize-ReleaseStageContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$RemainingPlugins, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..." + $remoteTagExists = Test-RemoteTagExists -Tag $SharedSettings.Tag -Remote "origin" + if (-not $remoteTagExists) { + Write-Log -Level "WARN" -Message " Tag $($SharedSettings.Tag) not found on remote. Pushing..." + Push-TagToRemote -Tag $SharedSettings.Tag -Remote "origin" + } + else { + Write-Log -Level "OK" -Message " Tag exists on remote." + } + + if (-not $SharedSettings.PSObject.Properties['ReleaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.ReleaseDir)) { + $SharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $ArtifactsDirectory -Force + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$UtilsDir + ) + + $dotNetContext = New-DotNetReleaseContext -Plugins $Plugins -ScriptDir $ScriptDir + + $currentBranch = Get-CurrentBranch + $releaseBranches = @( + $Plugins | + Where-Object { Test-IsPublishPlugin -Plugin $_ } | + ForEach-Object { Get-PluginBranches -Plugin $_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + + $isReleaseBranch = $releaseBranches -contains $currentBranch + $isNonReleaseBranch = -not $isReleaseBranch + + Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch + + $version = $dotNetContext.Version + + if ($isReleaseBranch) { + $tag = Get-CurrentCommitTag -Version $version + + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." + exit 1 + } + + $tagVersion = $Matches[1] + if ($tagVersion -ne $version) { + Write-Error "Tag version ($tagVersion) does not match the project version ($version)." + Write-Log -Level "WARN" -Message " Either update the tag or the project version." + exit 1 + } + + Write-Log -Level "OK" -Message " Tag found: $tag (matches project version)" + } + else { + $tag = "v$version" + Write-Log -Level "INFO" -Message " Using version from the package project (no tag required on non-release branches)." + } + + return [pscustomobject]@{ + ScriptDir = $ScriptDir + UtilsDir = $UtilsDir + CurrentBranch = $currentBranch + Version = $version + Tag = $tag + ProjectFiles = $dotNetContext.ProjectFiles + ArtifactsDirectory = $dotNetContext.ArtifactsDirectory + IsReleaseBranch = $isReleaseBranch + IsNonReleaseBranch = $isNonReleaseBranch + ReleaseBranches = $releaseBranches + NonReleaseBranches = @() + PublishCompleted = $false + } +} + +function Get-PreferredReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [psobject]$EngineContext + ) + + if ($EngineContext.ReleaseBranches.Count -gt 0) { + return $EngineContext.ReleaseBranches[0] + } + + return "main" +} + +Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch diff --git a/utils/Release-Package/PluginSupport.psm1 b/utils/Release-Package/PluginSupport.psm1 new file mode 100644 index 0000000..2663f6b --- /dev/null +++ b/utils/Release-Package/PluginSupport.psm1 @@ -0,0 +1,368 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function Import-PluginDependency { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName, + + [Parameter(Mandatory = $true)] + [string]$RequiredCommand + ) + + if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) { + return + } + + $moduleRoot = Split-Path $PSScriptRoot -Parent + $modulePath = Join-Path $moduleRoot "$ModuleName.psm1" + if (Test-Path $modulePath -PathType Leaf) { + # Import into the global session so the calling plugin can see the exported commands. + # Importing only into this module's scope would make the dependency invisible to the plugin. + Import-Module $modulePath -Force -Global -ErrorAction Stop + } + + if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { + throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'." + } +} + +function Get-ConfiguredPlugins { + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not $Settings.PSObject.Properties['Plugins'] -or $null -eq $Settings.Plugins) { + return @() + } + + # JSON can deserialize a single plugin as one object or multiple plugins as an array. + # Always return an array so the engine can loop without special-case logic. + if ($Settings.Plugins -is [System.Collections.IEnumerable] -and -not ($Settings.Plugins -is [string])) { + return @($Settings.Plugins) + } + + return @($Settings.Plugins) +} + +function Get-PluginStage { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['Stage'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.Stage)) { + return "Release" + } + + return [string]$Plugin.Stage +} + +function Get-PluginBranches { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) { + return @() + } + + # Strings are also IEnumerable in PowerShell, so exclude them or we would split into characters. + if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) { + return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) { + return @() + } + + return @([string]$Plugin.branches) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.Name)) { + return $false + } + + return @('GitHub', 'NuGet') -contains ([string]$Plugin.Name) +} + +function Get-PluginSettingValue { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) { + continue + } + + if (-not $plugin.PSObject.Properties[$PropertyName]) { + continue + } + + $value = $plugin.$PropertyName + if ($null -eq $value) { + continue + } + + if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { + continue + } + + return $value + } + + return $null +} + +function Get-PluginPathListSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $rawPaths = @() + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + + if ($null -eq $value) { + return @() + } + + # Same rule as above: treat a string as one path, not a char-by-char sequence. + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + $rawPaths += $value + } + else { + $rawPaths += $value + } + + $resolvedPaths = @() + foreach ($path in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$path)) { + continue + } + + $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path))) + } + + # Wrap again to stop PowerShell from unrolling a single-item array into a bare string. + return @($resolvedPaths) +} + +function Get-PluginPathSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { + return $null + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value))) +} + +function Get-ArchiveNamePattern { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) { + continue + } + + if (-not $plugin.Enabled) { + continue + } + + $allowedBranches = Get-PluginBranches -Plugin $plugin + if ($allowedBranches.Count -gt 0 -and -not ($allowedBranches -contains $CurrentBranch)) { + continue + } + + if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) { + return [string]$plugin.zipNamePattern + } + } + + return "release-{version}.zip" +} + +function Resolve-PluginModulePath { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory + ) + + $pluginFileName = "{0}.psm1" -f $Plugin.Name + $candidatePaths = @( + (Join-Path $PluginsDirectory $pluginFileName), + (Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName) + ) + + foreach ($candidatePath in $candidatePaths) { + if (Test-Path $candidatePath -PathType Leaf) { + return $candidatePath + } + } + + return $candidatePaths[0] +} + +function Test-PluginRunnable { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.Name)) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin entry with no Name." + } + return $false + } + + if (-not $Plugin.Enabled) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' (disabled)." + } + return $false + } + + if (Test-IsPublishPlugin -Plugin $Plugin) { + $allowedBranches = Get-PluginBranches -Plugin $Plugin + if ($allowedBranches.Count -eq 0) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' because no publish branches are configured." + } + return $false + } + + if (-not ($allowedBranches -contains $SharedSettings.CurrentBranch)) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' on branch '$($SharedSettings.CurrentBranch)'." + } + return $false + } + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory + if (-not (Test-Path $pluginModulePath -PathType Leaf)) { + if ($WriteLogs) { + Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath" + } + return $false + } + + return $true +} + +function New-PluginInvocationSettings { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $properties = @{} + foreach ($property in $Plugin.PSObject.Properties) { + $properties[$property.Name] = $property.Value + } + + # Plugins receive their own config plus a shared Context object that carries runtime artifacts. + $properties['Context'] = $SharedSettings + return [pscustomobject]$properties +} + +function Invoke-ConfiguredPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$PluginsDirectory, + + [Parameter(Mandatory = $false)] + [bool]$ContinueOnError = $true + ) + + if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -PluginsDirectory $PluginsDirectory -WriteLogs:$true)) { + return + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory + Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.Name)'..." + + try { + $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop + # Resolve Invoke-Plugin from the imported module explicitly so we call the plugin we just loaded, + # not some command with the same name from another module already in session. + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings + + & $invokeCommand -Settings $pluginSettings + Write-Log -Level "OK" -Message " Plugin '$($Plugin.Name)' completed." + } + catch { + Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.Name)' failed: $($_.Exception.Message)" + if (-not $ContinueOnError) { + exit 1 + } + } +} + +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStage, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/Release-Package/Release-Package.bat b/utils/Release-Package/Release-Package.bat new file mode 100644 index 0000000..6a4aba8 --- /dev/null +++ b/utils/Release-Package/Release-Package.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1" +pause diff --git a/utils/Release-Package/Release-Package.ps1 b/utils/Release-Package/Release-Package.ps1 new file mode 100644 index 0000000..3ac921c --- /dev/null +++ b/utils/Release-Package/Release-Package.ps1 @@ -0,0 +1,182 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven release engine. + +.DESCRIPTION + This script is the orchestration layer for release automation. + It loads scriptsettings.json, evaluates the configured plugins in order, + builds shared execution context, and invokes each plugin's Invoke-Plugin + entrypoint with that plugin's own settings object plus runtime context. + + The engine is intentionally generic: + - It does not embed release-provider-specific logic + - It preserves plugin execution order from scriptsettings.json + - It isolates plugin failures according to the stage/runtime policy + - It keeps shared orchestration helpers in dedicated support modules + +.REQUIREMENTS + Tools (Required): + - Shared support modules required by the engine + - Any commands required by configured plugins or support helpers + +.WORKFLOW + 1. Load and normalize plugin configuration + 2. Determine branch mode from configured plugin metadata + 3. Validate repository state and resolve the release version + 4. Build shared execution context + 5. Execute plugins one by one in configured order + 6. Initialize release-stage shared artifacts only when needed + 7. Report completion summary + +.USAGE + Configure plugin order and plugin settings in scriptsettings.json, then run: + pwsh -File .\Release-Package.ps1 + +.CONFIGURATION + All settings are stored in scriptsettings.json: + - Plugins: Ordered plugin definitions and plugin-specific settings + +.NOTES + Plugin-specific behavior belongs in the plugin modules, not in this engine. +#> + +# No parameters - behavior is controlled by configured plugin metadata: +# - non-release branches -> Run only the plugins allowed for those branches +# - release branches -> Require a matching tag and allow release-stage plugins + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +#region Import Modules + +$utilsDir = Split-Path $scriptDir -Parent + +# Import ScriptConfig module +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force + +# Import Logging module +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $loggingModulePath -Force +# Import PluginSupport module +$pluginSupportModulePath = Join-Path $scriptDir "PluginSupport.psm1" +if (-not (Test-Path $pluginSupportModulePath)) { + Write-Error "PluginSupport module not found at: $pluginSupportModulePath" + exit 1 +} + +Import-Module $pluginSupportModulePath -Force + +# Import DotNetProjectSupport module +$dotNetProjectSupportModulePath = Join-Path $scriptDir "DotNetProjectSupport.psm1" +if (-not (Test-Path $dotNetProjectSupportModulePath)) { + Write-Error "DotNetProjectSupport module not found at: $dotNetProjectSupportModulePath" + exit 1 +} + +Import-Module $dotNetProjectSupportModulePath -Force + +# Import EngineSupport module +$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1" +if (-not (Test-Path $engineSupportModulePath)) { + Write-Error "EngineSupport module not found at: $engineSupportModulePath" + exit 1 +} + +Import-Module $engineSupportModulePath -Force + +#endregion + +#region Load Settings +$settings = Get-ScriptSettings -ScriptDir $scriptDir +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +#endregion + +#region Configuration + +$pluginsDir = Join-Path $scriptDir "CorePlugins" + +#endregion + +#endregion + +#region Main + +Write-Log -Level "STEP" -Message "==================================================" +Write-Log -Level "STEP" -Message "RELEASE ENGINE" +Write-Log -Level "STEP" -Message "==================================================" + +#region Preflight + +$plugins = $configuredPlugins +$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir +Write-Log -Level "OK" -Message "All pre-flight checks passed!" +$sharedPluginSettings = $engineContext + +#endregion + +#region Plugin Execution + +$releaseStageInitialized = $false + +if ($plugins.Count -eq 0) { + Write-Log -Level "WARN" -Message "No plugins configured in scriptsettings.json." +} +else { + for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) { + $plugin = $plugins[$pluginIndex] + $pluginStage = Get-PluginStage -Plugin $plugin + + if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -WriteLogs:$false) { + $remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)]) + Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.ArtifactsDirectory -Version $engineContext.Version + $releaseStageInitialized = $true + } + } + + $continueOnError = $pluginStage -eq "Release" + Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -ContinueOnError:$continueOnError + } +} + +if (-not $releaseStageInitialized) { + Write-Log -Level "WARN" -Message "No release plugins executed for branch '$($engineContext.CurrentBranch)'." +} + +#endregion + +#region Summary +Write-Log -Level "OK" -Message "==================================================" +if ($engineContext.IsNonReleaseBranch) { + Write-Log -Level "OK" -Message "NON-RELEASE RUN COMPLETE" +} +else { + Write-Log -Level "OK" -Message "RELEASE COMPLETE" +} +Write-Log -Level "OK" -Message "==================================================" + +Write-Log -Level "INFO" -Message "Artifacts location: $($engineContext.ArtifactsDirectory)" + +if ($engineContext.IsNonReleaseBranch) { + $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext + Write-Log -Level "WARN" -Message "To execute release-stage plugins, rerun from an allowed release branch such as '$preferredReleaseBranch'." +} + +#endregion + +#endregion diff --git a/utils/Release-Package/scriptsettings.json b/utils/Release-Package/scriptsettings.json new file mode 100644 index 0000000..d75b8e1 --- /dev/null +++ b/utils/Release-Package/scriptsettings.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release Package Script Settings", + "description": "Configuration file for Release-Package.ps1 script.", + "Plugins": [ + { + "Name": "DotNetTest", + "Stage": "Test", + "Enabled": true, + "project": "..\\..\\src\\MaksIT.Dapr.Tests", + "resultsDir": "..\\..\\testResults" + }, + { + "Name": "QualityGate", + "Stage": "QualityGate", + "Enabled": true, + "coverageThreshold": 0, + "failOnVulnerabilities": true + }, + { + "Name": "DotNetPack", + "Stage": "Build", + "Enabled": true, + "projectFiles": [ + "..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" + ], + "artifactsDir": "..\\..\\release" + }, + { + "Name": "CreateArchive", + "Stage": "Build", + "Enabled": true, + "zipNamePattern": "maksit.dapr-{version}.zip" + }, + { + "Name": "GitHub", + "Stage": "Release", + "Enabled": true, + "branches": [ + "main" + ], + "githubToken": "GITHUB_MAKS_IT_COM", + "repository": "https://github.com/MAKS-IT-COM/maksit-dapr", + "releaseNotesFile": "..\\..\\CHANGELOG.md", + "releaseTitlePattern": "Release {version}" + }, + { + "Name": "NuGet", + "Stage": "Release", + "Enabled": true, + "branches": [ + "main" + ], + "nugetApiKey": "NUGET_MAKS_IT", + "source": "https://api.nuget.org/v3/index.json" + }, + { + "Name": "CleanupArtifacts", + "Stage": "Release", + "Enabled": true, + "includePatterns": [ + "*" + ], + "excludePatterns": [ + "*.zip" + ] + } + ], + "_comments": { + "Plugins": { + "Name": "Plugin module file name in CorePlugins (for example, DotNetPack -> CorePlugins/DotNetPack.psm1).", + "Stage": "Execution phase. Supported values are Test, QualityGate, Build, and Release.", + "Enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.", + "branches": "Used only by publish plugins such as GitHub and NuGet. When the current branch is not listed, publishing is skipped.", + "project": "DotNetTest plugin only. Path to the test project directory, relative to the script folder.", + "resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.", + "projectFiles": "DotNetPack, DotNetPublish, or another producer plugin can define the project files used for version discovery and artifact creation.", + "artifactsDir": "DotNetPack, DotNetPublish, or another producer plugin can define the artifacts output directory, relative to the script folder.", + "coverageThreshold": "QualityGate plugin only. Coverage threshold percent (0 disables threshold check).", + "failOnVulnerabilities": "QualityGate plugin only. If true, fail when vulnerable packages are detected.", + "githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.", + "repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.", + "releaseNotesFile": "GitHub plugin (or another notes consumer plugin) can define the release notes source file, relative to the script folder.", + "releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.", + "zipNamePattern": "CreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.", + "nugetApiKey": "NuGet plugin only. Environment variable name containing the NuGet API key.", + "source": "NuGet plugin only. Feed URL passed to dotnet nuget push.", + "includePatterns": "CleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).", + "excludePatterns": "CleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip'])." + } + } +} diff --git a/utils/ScriptConfig.psm1 b/utils/ScriptConfig.psm1 index 8b93dfc..738cd5c 100644 --- a/utils/ScriptConfig.psm1 +++ b/utils/ScriptConfig.psm1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + function Get-ScriptSettings { param( [Parameter(Mandatory = $true)] diff --git a/utils/TestRunner.psm1 b/utils/TestRunner.psm1 index 5de475a..f382b24 100644 --- a/utils/TestRunner.psm1 +++ b/utils/TestRunner.psm1 @@ -1,3 +1,6 @@ +#requires -Version 7.0 +#requires -PSEdition Core + <# .SYNOPSIS PowerShell module for running tests with code coverage. @@ -8,7 +11,7 @@ .NOTES Author: MaksIT - Usage: Import-Module .\TestRunner.psm1 + Usage: pwsh -Command "Import-Module .\TestRunner.psm1" #> function Import-LoggingModuleInternal { diff --git a/utils/Update-RepoUtils/Update-RepoUtils.bat b/utils/Update-RepoUtils/Update-RepoUtils.bat new file mode 100644 index 0000000..8ff94ac --- /dev/null +++ b/utils/Update-RepoUtils/Update-RepoUtils.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1" +pause diff --git a/utils/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/Update-RepoUtils/Update-RepoUtils.ps1 new file mode 100644 index 0000000..0e8a20d --- /dev/null +++ b/utils/Update-RepoUtils/Update-RepoUtils.ps1 @@ -0,0 +1,325 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Refreshes a local maksit-repoutils copy from GitHub. + +.DESCRIPTION + This script clones the configured repository into a temporary directory, + refreshes the parent directory of this script, preserves existing + scriptsettings.json files in subfolders, and copies the cloned source + contents into that parent directory. + + All configuration is stored in scriptsettings.json. + +.EXAMPLE + pwsh -File .\Update-RepoUtils.ps1 + +.NOTES + CONFIGURATION (scriptsettings.json): + - dryRun: If true, logs the planned update without modifying files + - repository.url: Git repository to clone + - repository.sourceSubdirectory: Folder copied into the target directory + - repository.preserveFileName: Existing file name to preserve in subfolders + - repository.cloneDepth: Depth used for git clone + - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh +#> + +[CmdletBinding()] +param( + [switch]$ContinueAfterSelfUpdate, + [string]$TargetDirectoryOverride, + [string]$ClonedSourceDirectoryOverride, + [string]$TemporaryRootOverride +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$utilsDir = Split-Path $scriptDir -Parent + +# Refresh the parent directory that contains the shared modules and sibling tools. +$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) { + Split-Path $scriptDir -Parent +} +else { + [System.IO.Path]::GetFullPath($TargetDirectoryOverride) +} +$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path) +$selfUpdateDirectory = 'Update-RepoUtils' + +#region Import Modules + +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +$repositoryUrl = $settings.repository.url +$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false } +$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' } +$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptsettings.json' } +$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 } +$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) { + @( + $settings.repository.skippedRelativeDirectories | + ForEach-Object { + ([string]$_).Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar) + } + ) +} +else { + @([System.IO.Path]::Combine('Release-Package', 'CustomPlugins')) +} + +#endregion + +#region Validate CLI Dependencies + +Assert-Command git +Assert-Command pwsh + +if ([string]::IsNullOrWhiteSpace($repositoryUrl)) { + Write-Error "repository.url is required in scriptsettings.json." + exit 1 +} + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Update RepoUtils Script" +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Target directory: $targetDirectory" +Write-Log -Level "INFO" -Message "Dry run: $dryRun" + +$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride) +$temporaryRoot = if ($ownsTemporaryRoot) { + Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N')) +} +else { + [System.IO.Path]::GetFullPath($TemporaryRootOverride) +} + +try { + $clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) { + Write-LogStep "Cloning latest repository snapshot..." + & git clone --depth $cloneDepth $repositoryUrl $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "git clone failed with exit code $LASTEXITCODE." + } + Write-Log -Level "OK" -Message "Repository cloned" + + Join-Path $temporaryRoot $sourceSubdirectory + } + else { + [System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride) + } + + if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) { + throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory" + } + + if (-not $ContinueAfterSelfUpdate) { + if ($dryRun) { + Write-LogStep "Dry run self-update summary" + Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater" + } + else { + Write-LogStep "Refreshing updater files..." + $selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar) + $isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + + $_.Name -ne $preserveFileName -and + ($isRootFile -or $isUpdaterFile) + } + + foreach ($sourceFile in $selfUpdateFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + Write-Log -Level "OK" -Message "Updater files refreshed" + } + + if ($dryRun) { + Write-LogStep "Dry run bootstrap completed" + Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed" + } + else { + Write-LogStep "Relaunching the updated updater..." + & pwsh -File $currentScriptPath ` + -ContinueAfterSelfUpdate ` + -TargetDirectoryOverride $targetDirectory ` + -ClonedSourceDirectoryOverride $clonedSourceDirectory ` + -TemporaryRootOverride $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "Relaunched updater failed with exit code $LASTEXITCODE." + } + + Write-Log -Level "OK" -Message "Bootstrap phase completed" + return + } + } + + $preservedFiles = @() + $updatePhaseSkippedDirectories = $skippedRelativeDirectories + $selfUpdateDirectory + $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue + if ($existingPreservedFiles) { + foreach ($file in $existingPreservedFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) + $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $preservedFiles += [pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + } + + if (-not $dryRun) { + Copy-Item -Path $file.FullName -Destination $backupPath -Force + } + } + Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" + } + else { + Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" + } + + if ($dryRun) { + Write-LogStep "Dry run summary" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" + if ($preservedFiles.Count -gt 0) { + $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " + Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList" + } + Write-Log -Level "OK" -Message "Dry run completed. No files were modified." + return + } + + Write-LogStep "Cleaning target directory..." + $filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + + $_.Name -ne $preserveFileName -and + -not $isInSkippedDirectory + } + + foreach ($file in $filesToRemove) { + Remove-Item -Path $file.FullName -Force + } + + $directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory | + Sort-Object { $_.FullName.Length } -Descending + + foreach ($directory in $directoriesToRemove) { + $remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue + if (-not $remainingItems) { + Remove-Item -Path $directory.FullName -Force + } + } + Write-Log -Level "OK" -Message "Target directory cleaned" + + Write-LogStep "Copying refreshed source files..." + $sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + + -not $isInSkippedDirectory + } + + foreach ($sourceFile in $sourceFilesToCopy) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + $skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory + if (Test-Path -Path $skippedSourcePath) { + Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" + } + } + Write-Log -Level "OK" -Message "Source files copied" + + if ($preservedFiles.Count -gt 0) { + foreach ($preservedFile in $preservedFiles) { + if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) { + continue + } + + $restorePath = Join-Path $targetDirectory $preservedFile.RelativePath + $restoreDirectory = Split-Path -Parent $restorePath + if (-not (Test-Path -Path $restoreDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null + } + + Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force + } + Write-Log -Level "OK" -Message "$preserveFileName files restored" + } + + Write-Log -Level "OK" -Message "========================================" + Write-Log -Level "OK" -Message "Update completed successfully!" + Write-Log -Level "OK" -Message "========================================" +} +finally { + if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) { + Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +#endregion diff --git a/utils/Update-RepoUtils/scriptsettings.json b/utils/Update-RepoUtils/scriptsettings.json new file mode 100644 index 0000000..9d55393 --- /dev/null +++ b/utils/Update-RepoUtils/scriptsettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Update RepoUtils Script Settings", + "description": "Configuration for the Update-RepoUtils utility.", + "dryRun": true, + "repository": { + "url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git", + "sourceSubdirectory": "src", + "preserveFileName": "scriptsettings.json", + "cloneDepth": 1, + "skippedRelativeDirectories": [ + "Release-Package/CustomPlugins" + ] + } +}