Compare commits
4 Commits
84e8a4d9e8
...
f749de4c79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f749de4c79 | ||
|
|
9cee601916 | ||
|
|
02d8cd64dd | ||
|
|
4a4c0ea29e |
@ -34,7 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Security
|
||||
- Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk.
|
||||
|
||||
|
||||
<!--
|
||||
Template for new releases:
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ Thank you for your interest in contributing to MaksIT.Core! This document provid
|
||||
|
||||
```bash
|
||||
cd src
|
||||
dotnet build MaksIT.Core.sln
|
||||
dotnet build MaksIT.Core.slnx
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@ -246,4 +246,4 @@ If the release partially failed (e.g., NuGet succeeded but GitHub failed):
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
By contributing, you agree that your contributions are licensed under the terms in `LICENSE.md`.
|
||||
|
||||
@ -1614,7 +1614,10 @@ string completedName = completed.GetDisplayName(); // "Completed"
|
||||
|
||||
## Contact
|
||||
|
||||
For any inquiries or contributions, feel free to reach out:
|
||||
If you have any questions or need further assistance, feel free to reach out:
|
||||
|
||||
- **Email**: maksym.sadovnychyy@gmail.com
|
||||
- **Author**: Maksym Sadovnychyy (MAKS-IT)
|
||||
- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE.md`.
|
||||
@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.6%">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.6%">
|
||||
<title>Branch Coverage: 49.6%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 60%">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 60%">
|
||||
<title>Line Coverage: 60%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 69.2%">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 69.2%">
|
||||
<title>Method Coverage: 69.2%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,31 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.002.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Core", "MaksIT.Core\MaksIT.Core.csproj", "{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.Core.Tests", "MaksIT.Core.Tests\MaksIT.Core.Tests.csproj", "{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {9BCC72D1-8BE8-4924-AF73-C8E86E16EC59}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
4
src/MaksIT.Core.slnx
Normal file
4
src/MaksIT.Core.slnx
Normal file
@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
||||
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
||||
</Solution>
|
||||
@ -1,3 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
pause
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
pause
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Amends the latest tagged commit and force-pushes updated branch and tag.
|
||||
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs the following operations:
|
||||
@ -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):
|
||||
@ -66,6 +69,29 @@ Import-Module $gitToolsModulePath -Force
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
function Select-PreferredHeadTag {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Tags
|
||||
)
|
||||
|
||||
# Pick the latest tag on HEAD by git's own ordering (no tag-name parsing assumptions).
|
||||
$ordered = (& git tag --points-at HEAD --sort=-creatordate 2>$null)
|
||||
if ($LASTEXITCODE -eq 0 -and $ordered) {
|
||||
$orderedTags = @($ordered | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
|
||||
if ($orderedTags.Count -gt 0) {
|
||||
return $orderedTags[0]
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback: keep script functional even if sorting is unavailable.
|
||||
return $Tags[0]
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
|
||||
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||
@ -110,14 +136,17 @@ Write-Log -Level "INFO" -Message "Commit: $CommitHash - $CommitMessage"
|
||||
|
||||
# 3. Ensure HEAD has at least one tag
|
||||
Write-LogStep "Finding tag on last commit..."
|
||||
$tags = @(Get-HeadTags)
|
||||
$tags = Get-HeadTags
|
||||
if ($tags.Count -eq 0) {
|
||||
Write-Error "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# If multiple tags exist, use the first one returned by git.
|
||||
$TagName = $tags[0]
|
||||
# If multiple tags exist, choose the latest one on HEAD by git ordering.
|
||||
if ($tags.Count -gt 1) {
|
||||
Write-Log -Level "WARN" -Message "Multiple tags found on HEAD: $($tags -join ', ')"
|
||||
}
|
||||
$TagName = Select-PreferredHeadTag -Tags $tags
|
||||
Write-Log -Level "OK" -Message "Found tag: $TagName"
|
||||
|
||||
# 4. Inspect pending changes before amend
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||
pause
|
||||
|
||||
@ -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%"
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
#
|
||||
# Shared Git helpers for utility scripts.
|
||||
#
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
function Get-LogTimestampInternal {
|
||||
return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||
pause
|
||||
@ -1,766 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds, tests, packs, and publishes MaksIT.Core to NuGet and GitHub releases.
|
||||
|
||||
.DESCRIPTION
|
||||
This script automates the release process for MaksIT.Core library.
|
||||
The script is IDEMPOTENT - you can safely re-run it if any step fails.
|
||||
It will skip already-completed steps (NuGet and GitHub) and only create what's missing.
|
||||
GitHub repository target can be configured explicitly in scriptsettings.json.
|
||||
|
||||
Features:
|
||||
- Validates environment and prerequisites
|
||||
- Checks if version already exists on NuGet.org (skips if released)
|
||||
- Checks if GitHub release exists (skips if released)
|
||||
- Scans for vulnerable packages (security check)
|
||||
- Builds and tests the project (Windows + Linux via Docker)
|
||||
- Collects code coverage with Coverlet (threshold enforcement optional)
|
||||
- Generates test result artifacts (TRX format) and coverage reports
|
||||
- Displays test results with pass/fail counts and coverage percentage
|
||||
- Publishes to NuGet.org
|
||||
- Creates a GitHub release with changelog and package assets
|
||||
- Shows timing summary for all steps
|
||||
|
||||
.REQUIREMENTS
|
||||
Environment Variables:
|
||||
- NUGET_MAKS_IT : NuGet.org API key for publishing packages
|
||||
- GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope)
|
||||
|
||||
Tools (Required):
|
||||
- dotnet CLI : For building, testing, and packing
|
||||
- git : For version control operations
|
||||
- gh (GitHub CLI) : For creating GitHub releases
|
||||
- docker : For cross-platform Linux testing
|
||||
|
||||
.WORKFLOW
|
||||
1. VALIDATION PHASE
|
||||
- Check required environment variables (NuGet key, GitHub token)
|
||||
- Check required tools are installed (dotnet, git, gh, docker)
|
||||
- Verify no uncommitted changes in working directory
|
||||
- Authenticate GitHub CLI
|
||||
|
||||
2. VERSION & RELEASE CHECK PHASE (Idempotent)
|
||||
- Read latest version from CHANGELOG.md
|
||||
- Find commit with matching version tag
|
||||
- Validate tag is on configured release branch (from scriptsettings.json)
|
||||
- Check if already released on NuGet.org (mark for skip if yes)
|
||||
- Check if GitHub release exists (mark for skip if yes)
|
||||
- Read target framework from MaksIT.Core.csproj
|
||||
- Extract release notes from CHANGELOG.md for current version
|
||||
|
||||
3. SECURITY SCAN
|
||||
- Check for vulnerable packages (dotnet list package --vulnerable)
|
||||
- Fail or warn based on $failOnVulnerabilities setting
|
||||
|
||||
4. BUILD & TEST PHASE
|
||||
- Clean previous builds (delete bin/obj folders)
|
||||
- Restore NuGet packages
|
||||
- Windows: Build main project -> Build test project -> Run tests with coverage
|
||||
- Analyze code coverage (fail if below threshold when configured)
|
||||
- Linux (Docker): Build main project -> Build test project -> Run tests (TRX report)
|
||||
- Rebuild for Windows (Docker may overwrite bin/obj)
|
||||
- Create NuGet package (.nupkg) and symbols (.snupkg)
|
||||
- All steps are timed for performance tracking
|
||||
|
||||
5. CONFIRMATION PHASE
|
||||
- Display release summary
|
||||
- Prompt user for confirmation before proceeding
|
||||
|
||||
6. NUGET RELEASE PHASE (Idempotent)
|
||||
- Skip if version already exists on NuGet.org
|
||||
- Otherwise, push package to NuGet.org
|
||||
|
||||
7. GITHUB RELEASE PHASE (Idempotent)
|
||||
- Skip if release already exists
|
||||
- Push tag to remote if not already there
|
||||
- Create GitHub release with:
|
||||
* Release notes from CHANGELOG.md
|
||||
* .nupkg and .snupkg as downloadable assets
|
||||
|
||||
8. COMPLETION PHASE
|
||||
- Display timing summary for all steps
|
||||
- Display test results summary
|
||||
- Display success summary with links
|
||||
- Open NuGet and GitHub release pages in browser
|
||||
- TODO: Email notification (template provided)
|
||||
- TODO: Package signing (template provided)
|
||||
|
||||
.USAGE
|
||||
Before running:
|
||||
1. Ensure Docker Desktop is running (for Linux tests)
|
||||
2. Update version in MaksIT.Core.csproj
|
||||
3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md
|
||||
4. Review and commit all changes
|
||||
5. Create version tag: git tag v1.x.x
|
||||
6. Run: .\Release-NuGetPackage.ps1
|
||||
|
||||
Note: The script finds the commit with the tag matching CHANGELOG.md version.
|
||||
You can run it from any branch/commit - it releases the tagged commit.
|
||||
|
||||
Re-run release (idempotent - skips NuGet/GitHub if already released):
|
||||
.\Release-NuGetPackage.ps1
|
||||
|
||||
Generate changelog and update LICENSE year:
|
||||
.\Generate-Changelog.ps1
|
||||
|
||||
.CONFIGURATION
|
||||
All settings are stored in scriptsettings.json:
|
||||
- packageSigning: Code signing certificate configuration
|
||||
- emailNotification: SMTP settings for release notifications
|
||||
|
||||
.NOTES
|
||||
Author: Maksym Sadovnychyy (MAKS-IT)
|
||||
Repository: https://github.com/MAKS-IT-COM/maksit-core
|
||||
#>
|
||||
|
||||
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
|
||||
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
|
||||
# - release branch -> Full release to GitHub (tag required, clean working directory)
|
||||
|
||||
# Get the directory of the current script (for loading settings and relative paths)
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
#region Import Modules
|
||||
|
||||
# Import TestRunner module
|
||||
$utilsDir = Split-Path $scriptDir -Parent
|
||||
|
||||
$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1"
|
||||
if (-not (Test-Path $testRunnerModulePath)) {
|
||||
Write-Error "TestRunner module not found at: $testRunnerModulePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Import-Module $testRunnerModulePath -Force
|
||||
|
||||
# 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 GitTools module
|
||||
$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
|
||||
if (-not (Test-Path $gitToolsModulePath)) {
|
||||
Write-Error "GitTools module not found at: $gitToolsModulePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Import-Module $gitToolsModulePath -Force
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
|
||||
# GitHub configuration
|
||||
$githubReleseEnabled = $settings.github.enabled
|
||||
$githubTokenEnvVar = $settings.github.githubToken
|
||||
$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
|
||||
$githubRepositorySetting = $settings.github.repository
|
||||
|
||||
# NuGet configuration
|
||||
$nugetReleseEnabled = $settings.nuget.enabled
|
||||
$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey
|
||||
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
|
||||
$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" }
|
||||
|
||||
# Paths from settings (resolve relative to script directory)
|
||||
$csprojPaths = @()
|
||||
$rawCsprojPaths = @()
|
||||
|
||||
if ($settings.paths.csprojPaths) {
|
||||
if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) {
|
||||
$rawCsprojPaths += $settings.paths.csprojPaths
|
||||
}
|
||||
else {
|
||||
$rawCsprojPaths += $settings.paths.csprojPaths
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
foreach ($path in $rawCsprojPaths) {
|
||||
if ([string]::IsNullOrWhiteSpace($path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
|
||||
$csprojPaths += $resolvedPath
|
||||
}
|
||||
|
||||
if ($csprojPaths.Count -eq 0) {
|
||||
Write-Error "No valid csproj paths configured in scriptsettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir))
|
||||
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
|
||||
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
|
||||
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
|
||||
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
|
||||
|
||||
# Release naming patterns
|
||||
$zipNamePattern = $settings.release.zipNamePattern
|
||||
$releaseTitlePattern = $settings.release.releaseTitlePattern
|
||||
|
||||
# Branch configuration
|
||||
$releaseBranch = $settings.branches.release
|
||||
$devBranch = $settings.branches.dev
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
# Helper: extract a csproj property (first match)
|
||||
function Get-CsprojPropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][xml]$csproj,
|
||||
[Parameter(Mandatory=$true)][string]$propertyName
|
||||
)
|
||||
|
||||
$propNode = $csproj.Project.PropertyGroup |
|
||||
Where-Object { $_.$propertyName } |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($propNode) {
|
||||
return $propNode.$propertyName
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Helper: resolve output assembly name for published exe
|
||||
function Resolve-ProjectExeName {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$projPath
|
||||
)
|
||||
|
||||
[xml]$csproj = Get-Content $projPath
|
||||
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
|
||||
if ($assemblyName) {
|
||||
return $assemblyName
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||
}
|
||||
|
||||
# Helper: check for uncommitted changes
|
||||
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
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "OK" -Message " Working directory is clean."
|
||||
}
|
||||
}
|
||||
|
||||
# Helper: read versions from csproj files
|
||||
function Get-CsprojVersions {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$CsprojPaths
|
||||
)
|
||||
|
||||
Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..."
|
||||
$projectVersions = @{}
|
||||
|
||||
foreach ($projPath in $CsprojPaths) {
|
||||
if (-not (Test-Path $projPath -PathType Leaf)) {
|
||||
Write-Error "Csproj file not found at: $projPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") {
|
||||
Write-Error "Configured path is not a .csproj file: $projPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[xml]$csproj = Get-Content $projPath
|
||||
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
|
||||
|
||||
if (-not $version) {
|
||||
Write-Error "Version not found in $projPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$projectVersions[$projPath] = $version
|
||||
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version"
|
||||
}
|
||||
|
||||
return $projectVersions
|
||||
}
|
||||
|
||||
# Helper: resolve GitHub repository (owner/repo) from settings override or remote URL
|
||||
function Resolve-GitHubRepository {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$RepositorySetting
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($RepositorySetting)) {
|
||||
$value = $RepositorySetting.Trim()
|
||||
|
||||
if ($value -match '^https?://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+?)(?:\.git)?/?$') {
|
||||
return "$($Matches['owner'])/$($Matches['repo'])"
|
||||
}
|
||||
|
||||
if ($value -match '^(?<owner>[^/]+)/(?<repo>[^/]+)$') {
|
||||
return "$($Matches['owner'])/$($Matches['repo'])"
|
||||
}
|
||||
|
||||
Write-Error "Invalid github.repository format '$value'. Use 'owner/repo' or 'https://github.com/owner/repo'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$remoteUrl = git config --get remote.origin.url
|
||||
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
|
||||
Write-Error "Could not determine git remote origin URL. Configure github.repository in scriptsettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||
return "$($Matches['owner'])/$($Matches['repo'])"
|
||||
}
|
||||
|
||||
Write-Error "Could not parse repository from remote URL: $remoteUrl. Configure github.repository in scriptsettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate CLI Dependencies
|
||||
|
||||
Assert-Command dotnet
|
||||
Assert-Command git
|
||||
Assert-Command docker
|
||||
# gh command check deferred until after branch detection (only needed on release branch)
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
Write-Log -Level "STEP" -Message "=================================================="
|
||||
Write-Log -Level "STEP" -Message "RELEASE BUILD"
|
||||
Write-Log -Level "STEP" -Message "=================================================="
|
||||
|
||||
#region Preflight
|
||||
|
||||
$isDevBranch = $false
|
||||
$isReleaseBranch = $false
|
||||
|
||||
# 1. Detect current branch and determine release mode
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
$isDevBranch = $currentBranch -eq $devBranch
|
||||
$isReleaseBranch = $currentBranch -eq $releaseBranch
|
||||
|
||||
if (-not $isDevBranch -and -not $isReleaseBranch) {
|
||||
Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 2. Check for uncommitted changes (required on release branch, allowed on dev)
|
||||
Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch
|
||||
|
||||
# 3. Get version from csproj (source of truth)
|
||||
$projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths
|
||||
|
||||
# Use the first project's version as the release version
|
||||
$version = $projectVersions[$csprojPaths[0]]
|
||||
|
||||
# 4. Handle tag based on branch
|
||||
if ($isReleaseBranch) {
|
||||
# Release branch: tag is required and must match version
|
||||
$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 csproj version ($version)."
|
||||
Write-Log -Level "WARN" -Message " Either update the tag or the csproj version."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)"
|
||||
}
|
||||
else {
|
||||
# Dev branch: no tag required, use version from csproj
|
||||
$tag = "v$version"
|
||||
Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)."
|
||||
}
|
||||
|
||||
# 5. Verify CHANGELOG.md has matching version entry
|
||||
Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..."
|
||||
if (-not (Test-Path $changelogPath)) {
|
||||
Write-Error "CHANGELOG.md not found at: $changelogPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changelog = Get-Content $changelogPath -Raw
|
||||
|
||||
if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
|
||||
Write-Error "No version entry found in CHANGELOG.md"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changelogVersion = $Matches[1]
|
||||
|
||||
if ($changelogVersion -ne $version) {
|
||||
Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
|
||||
Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion"
|
||||
|
||||
|
||||
|
||||
Write-Log -Level "OK" -Message "All pre-flight checks passed!"
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test
|
||||
|
||||
Write-Log -Level "STEP" -Message "Running tests..."
|
||||
|
||||
# Run tests using TestRunner module
|
||||
$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent
|
||||
|
||||
if (-not $testResult.Success) {
|
||||
Write-Error "Tests failed. Release aborted."
|
||||
Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
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)%"
|
||||
|
||||
#endregion
|
||||
|
||||
#region Build And Publish
|
||||
|
||||
# 7. Prepare staging directory
|
||||
Write-Log -Level "STEP" -Message "Preparing staging directory..."
|
||||
if (Test-Path $stagingDir) {
|
||||
Remove-Item $stagingDir -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $stagingDir | Out-Null
|
||||
|
||||
$binDir = Join-Path $stagingDir "bin"
|
||||
|
||||
# 8. Publish the project to staging/bin
|
||||
|
||||
Write-Log -Level "STEP" -Message "Publishing projects to bin folder..."
|
||||
$publishSuccess = $true
|
||||
$publishedProjects = @()
|
||||
|
||||
foreach ($projPath in $csprojPaths) {
|
||||
$projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||
$projBinDir = Join-Path $binDir $projName
|
||||
|
||||
dotnet publish $projPath -c Release -o $projBinDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet publish failed for $projName."
|
||||
$publishSuccess = $false
|
||||
}
|
||||
else {
|
||||
$exeBaseName = Resolve-ProjectExeName -projPath $projPath
|
||||
$publishedProjects += [PSCustomObject]@{
|
||||
ProjPath = $projPath
|
||||
ProjName = $projName
|
||||
BinDir = $projBinDir
|
||||
ExeBaseName = $exeBaseName
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Published $projName successfully to: $projBinDir"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $publishSuccess) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
# 12. Prepare release directory
|
||||
if (!(Test-Path $releaseDir)) {
|
||||
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
||||
}
|
||||
|
||||
|
||||
# 13. Create zip file
|
||||
$zipName = $zipNamePattern
|
||||
$zipName = $zipName -replace '\{version\}', $version
|
||||
$zipPath = Join-Path $releaseDir $zipName
|
||||
|
||||
if (Test-Path $zipPath) {
|
||||
Remove-Item $zipPath -Force
|
||||
}
|
||||
|
||||
Write-Log -Level "STEP" -Message "Creating archive $zipName..."
|
||||
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
|
||||
|
||||
if (-not (Test-Path $zipPath)) {
|
||||
Write-Error "Failed to create archive $zipPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Archive created: $zipPath"
|
||||
|
||||
# 14. Pack NuGet package and resolve produced .nupkg file
|
||||
$packageProjectPath = $csprojPaths[0]
|
||||
Write-Log -Level "STEP" -Message "Packing NuGet package..."
|
||||
dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet pack failed for $packageProjectPath."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$packageFile = Get-ChildItem -Path $releaseDir -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) {
|
||||
Write-Error "Could not locate generated NuGet package for version $version in: $releaseDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
|
||||
|
||||
# 15. Extract release notes from CHANGELOG.md
|
||||
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($changelog, $pattern)
|
||||
|
||||
if (-not $match.Success) {
|
||||
Write-Error "Changelog entry for version $version not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$releaseNotes = $match.Value.Trim()
|
||||
Write-Log -Level "OK" -Message " Release notes extracted."
|
||||
|
||||
# 16. Resolve repository info for GitHub release
|
||||
$repo = Resolve-GitHubRepository -RepositorySetting $githubRepositorySetting
|
||||
|
||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||
|
||||
Write-Log -Level "STEP" -Message "Release Summary:"
|
||||
Write-Log -Level "INFO" -Message " Repository: $repo"
|
||||
Write-Log -Level "INFO" -Message " Tag: $tag"
|
||||
Write-Log -Level "INFO" -Message " Title: $releaseName"
|
||||
|
||||
# 17. Check if tag is pushed to remote (skip on dev branch)
|
||||
|
||||
if (-not $isDevBranch) {
|
||||
|
||||
Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
|
||||
$remoteTagExists = Test-RemoteTagExists -Tag $tag -Remote "origin"
|
||||
if (-not $remoteTagExists) {
|
||||
Write-Log -Level "WARN" -Message " Tag $tag not found on remote. Pushing..."
|
||||
Push-TagToRemote -Tag $tag -Remote "origin"
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "OK" -Message " Tag exists on remote."
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Release to GitHub
|
||||
if ($githubReleseEnabled) {
|
||||
|
||||
Write-Log -Level "STEP" -Message " Release branch ($releaseBranch) - will publish to GitHub."
|
||||
Assert-Command gh
|
||||
|
||||
# 6. Check GitHub authentication
|
||||
|
||||
Write-Log -Level "INFO" -Message "Checking GitHub authentication..."
|
||||
if (-not $githubToken) {
|
||||
Write-Error "GitHub token is not set. Set '$githubTokenEnvVar' and rerun."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# gh release subcommands do not support custom auth headers.
|
||||
# Scope GH_TOKEN to this block so all gh commands authenticate with the configured token.
|
||||
$previousGhToken = $env:GH_TOKEN
|
||||
$env:GH_TOKEN = $githubToken
|
||||
|
||||
try {
|
||||
$authTest = & gh api user 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
|
||||
Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope."
|
||||
exit 1
|
||||
}
|
||||
Write-Log -Level "OK" -Message " GitHub CLI authenticated."
|
||||
|
||||
# 18. Create or update GitHub release
|
||||
Write-Log -Level "STEP" -Message "Creating GitHub release..."
|
||||
|
||||
# Check if release already exists
|
||||
$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) {
|
||||
Write-Error "Failed to delete existing release $tag."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Create release using the existing tag
|
||||
# Write release notes to a temp file to avoid shell interpretation issues with special characters
|
||||
$notesFilePath = Join-Path $releaseDir "release-notes-temp.md"
|
||||
[System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$createReleaseArgs = @(
|
||||
"release", "create", $tag, $zipPath
|
||||
"--repo", $repo
|
||||
"--title", $releaseName
|
||||
"--notes-file", $notesFilePath
|
||||
)
|
||||
& gh @createReleaseArgs
|
||||
|
||||
$ghExitCode = $LASTEXITCODE
|
||||
|
||||
# Cleanup temp notes file
|
||||
if (Test-Path $notesFilePath) {
|
||||
Remove-Item $notesFilePath -Force
|
||||
}
|
||||
|
||||
if ($ghExitCode -ne 0) {
|
||||
Write-Error "Failed to create GitHub release for tag $tag."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $previousGhToken) {
|
||||
$env:GH_TOKEN = $previousGhToken
|
||||
}
|
||||
else {
|
||||
Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " GitHub release created successfully."
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message "Skipping GitHub release (disabled)."
|
||||
}
|
||||
|
||||
|
||||
# Release to NuGet
|
||||
|
||||
if ($nugetReleseEnabled) {
|
||||
Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
|
||||
dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to push the package to NuGet."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " NuGet push completed."
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message "Skipping NuGet publish (disabled)."
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message "Skipping remote tag verification and GitHub release (dev branch)."
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
if (Test-Path $stagingDir) {
|
||||
Remove-Item $stagingDir -Recurse -Force
|
||||
Write-Log -Level "INFO" -Message " Cleaned up staging directory."
|
||||
}
|
||||
|
||||
if (Test-Path $testResultsDir) {
|
||||
Remove-Item $testResultsDir -Recurse -Force
|
||||
Write-Log -Level "INFO" -Message " Cleaned up test results directory."
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
Write-Log -Level "OK" -Message "=================================================="
|
||||
if ($isDevBranch) {
|
||||
Write-Log -Level "OK" -Message "DEV BUILD COMPLETE"
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "OK" -Message "RELEASE COMPLETE"
|
||||
}
|
||||
Write-Log -Level "OK" -Message "=================================================="
|
||||
|
||||
if (-not $isDevBranch) {
|
||||
Write-Log -Level "STEP" -Message "Release URL: https://github.com/$repo/releases/tag/$tag"
|
||||
}
|
||||
|
||||
Write-Log -Level "INFO" -Message "Artifacts location: $releaseDir"
|
||||
|
||||
if ($isDevBranch) {
|
||||
Write-Log -Level "WARN" -Message "To publish to GitHub, switch to '$releaseBranch', merge dev, tag, and run this script again:"
|
||||
Write-Log -Level "WARN" -Message " git checkout $releaseBranch"
|
||||
Write-Log -Level "WARN" -Message " git merge dev"
|
||||
Write-Log -Level "WARN" -Message " git tag v$version"
|
||||
Write-Log -Level "WARN" -Message " .\Release-NuGetPackage.ps1"
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
@ -1,67 +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-core"
|
||||
},
|
||||
|
||||
"nuget": {
|
||||
"enabled": true,
|
||||
"nugetApiKey": "NUGET_MAKS_IT",
|
||||
"source": "https://api.nuget.org/v3/index.json"
|
||||
},
|
||||
|
||||
"branches": {
|
||||
"release": "main",
|
||||
"dev": "dev"
|
||||
},
|
||||
|
||||
"paths": {
|
||||
"csprojPaths": [
|
||||
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
|
||||
],
|
||||
"testResultsDir": "..\\..\\testResults",
|
||||
"stagingDir": "..\\..\\staging",
|
||||
"releaseDir": "..\\..\\release",
|
||||
"changelogPath": "..\\..\\CHANGELOG.md",
|
||||
"testProject": "..\\..\\src\\MaksIT.Core.Tests"
|
||||
},
|
||||
|
||||
"release": {
|
||||
"zipNamePattern": "maksit.core-{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.",
|
||||
"stagingDir": "Temporary staging directory before archive creation.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
121
utils/Release-Package/CorePlugins/CleanupArtifacts.psm1
Normal file
121
utils/Release-Package/CorePlugins/CleanupArtifacts.psm1
Normal file
@ -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
|
||||
93
utils/Release-Package/CorePlugins/CreateArchive.psm1
Normal file
93
utils/Release-Package/CorePlugins/CreateArchive.psm1
Normal file
@ -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
|
||||
99
utils/Release-Package/CorePlugins/DotNetPack.psm1
Normal file
99
utils/Release-Package/CorePlugins/DotNetPack.psm1
Normal file
@ -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
|
||||
71
utils/Release-Package/CorePlugins/DotNetPublish.psm1
Normal file
71
utils/Release-Package/CorePlugins/DotNetPublish.psm1
Normal file
@ -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
|
||||
72
utils/Release-Package/CorePlugins/DotNetTest.psm1
Normal file
72
utils/Release-Package/CorePlugins/DotNetTest.psm1
Normal file
@ -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
|
||||
232
utils/Release-Package/CorePlugins/GitHub.psm1
Normal file
232
utils/Release-Package/CorePlugins/GitHub.psm1
Normal file
@ -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[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||
return "$($matches['owner'])/$($matches['repo'])"
|
||||
}
|
||||
|
||||
if ($repoSource -match "^(?<owner>[^/]+)/(?<repo>[^/]+)$") {
|
||||
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
|
||||
67
utils/Release-Package/CorePlugins/NuGet.psm1
Normal file
67
utils/Release-Package/CorePlugins/NuGet.psm1
Normal file
@ -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
|
||||
119
utils/Release-Package/CorePlugins/QualityGate.psm1
Normal file
119
utils/Release-Package/CorePlugins/QualityGate.psm1
Normal file
@ -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
|
||||
1
utils/Release-Package/CustomPlugins/.gitkeep
Normal file
1
utils/Release-Package/CustomPlugins/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
110
utils/Release-Package/DotNetProjectSupport.psm1
Normal file
110
utils/Release-Package/DotNetProjectSupport.psm1
Normal file
@ -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
|
||||
165
utils/Release-Package/EngineSupport.psm1
Normal file
165
utils/Release-Package/EngineSupport.psm1
Normal file
@ -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
|
||||
368
utils/Release-Package/PluginSupport.psm1
Normal file
368
utils/Release-Package/PluginSupport.psm1
Normal file
@ -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
|
||||
3
utils/Release-Package/Release-Package.bat
Normal file
3
utils/Release-Package/Release-Package.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
|
||||
pause
|
||||
182
utils/Release-Package/Release-Package.ps1
Normal file
182
utils/Release-Package/Release-Package.ps1
Normal file
@ -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
|
||||
92
utils/Release-Package/scriptsettings.json
Normal file
92
utils/Release-Package/scriptsettings.json
Normal file
@ -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.Core.Tests",
|
||||
"resultsDir": "..\\..\\testResults"
|
||||
},
|
||||
{
|
||||
"Name": "QualityGate",
|
||||
"Stage": "QualityGate",
|
||||
"Enabled": true,
|
||||
"coverageThreshold": 0,
|
||||
"failOnVulnerabilities": true
|
||||
},
|
||||
{
|
||||
"Name": "DotNetPack",
|
||||
"Stage": "Build",
|
||||
"Enabled": true,
|
||||
"projectFiles": [
|
||||
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
|
||||
],
|
||||
"artifactsDir": "..\\..\\release"
|
||||
},
|
||||
{
|
||||
"Name": "CreateArchive",
|
||||
"Stage": "Build",
|
||||
"Enabled": true,
|
||||
"zipNamePattern": "maksit.core-{version}.zip"
|
||||
},
|
||||
{
|
||||
"Name": "GitHub",
|
||||
"Stage": "Release",
|
||||
"Enabled": true,
|
||||
"branches": [
|
||||
"main"
|
||||
],
|
||||
"githubToken": "GITHUB_MAKS_IT_COM",
|
||||
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
|
||||
"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'])."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
function Get-ScriptSettings {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
3
utils/Update-RepoUtils/Update-RepoUtils.bat
Normal file
3
utils/Update-RepoUtils/Update-RepoUtils.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
|
||||
pause
|
||||
316
utils/Update-RepoUtils/Update-RepoUtils.ps1
Normal file
316
utils/Update-RepoUtils/Update-RepoUtils.ps1
Normal file
@ -0,0 +1,316 @@
|
||||
#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
|
||||
#>
|
||||
|
||||
[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'
|
||||
$skippedRelativeDirectories = @(
|
||||
[System.IO.Path]::Combine('Release-Package', 'CustomPlugins')
|
||||
)
|
||||
|
||||
#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 }
|
||||
|
||||
#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
|
||||
15
utils/Update-RepoUtils/scriptsettings.json
Normal file
15
utils/Update-RepoUtils/scriptsettings.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user