(feature): centralize release tooling, logging, and package/release flow
This commit is contained in:
parent
b8f6617da6
commit
4a4c0ea29e
3
.gitignore
vendored
3
.gitignore
vendored
@ -260,3 +260,6 @@ paket-files/
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
||||
/staging
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@ -5,32 +5,35 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.6.3 - 2026-02-13
|
||||
|
||||
### Changed
|
||||
- Updated dependencies to latest versions for improved performance and security.
|
||||
|
||||
## v1.6.2 - 2026-02-13
|
||||
## v1.6.3 - 2026-02-21
|
||||
|
||||
### Added
|
||||
- `BaseFileLogger` idempotent log folder creation and tests
|
||||
- New shared utility modules under `utils/`:
|
||||
- `Logging.psm1` for timestamped, aligned log output.
|
||||
- `ScriptConfig.psm1` for shared settings loading and command assertions.
|
||||
- `GitTools.psm1` for reusable git operations.
|
||||
- `TestRunner.psm1` for shared test/coverage execution.
|
||||
- New `Generate-CoverageBadges` utility script and settings to generate SVG coverage badges.
|
||||
|
||||
### Changed
|
||||
- Improved `BaseFileLogger` to ensure log folder is recreated if deleted during runtime (idempotent folder creation).
|
||||
- Added comprehensive tests verifying log folder recreation and robustness against folder deletion scenarios.
|
||||
- Removed AI assisted CHANGELOG.md generation as it's weak and not worth the effort.
|
||||
- Refactored release/amend/badges scripts to a modular structure with shared modules.
|
||||
- Standardized script structure with regions and clearer comments.
|
||||
- Switched script output to centralized logging format with timestamps (where logging module is available).
|
||||
- Updated release settings comments (`_comments`) for clarity and accuracy.
|
||||
- Updated `README.md` to show coverage badges.
|
||||
|
||||
## v1.6.1 - 2026-31-01
|
||||
### Removed
|
||||
- Removed legacy scripts from `src/scripts/` in favor of the `utils/`-based toolchain.
|
||||
- Removed unused helper logic (including obsolete step-wrapper usage and unused csproj helper).
|
||||
|
||||
### Added
|
||||
- Added `CreateMutex` method to `BaseFileLogger`
|
||||
- Added `ResolveFolderPath` and `SanitizeForPath` methods to `FileLoggerProvider`
|
||||
- Added `ResolveFolderPath` and `SanitizeForPath` methods to `JsonFileLoggerProvider`
|
||||
- Added `LoggerPrefix` class for managing logger prefixes
|
||||
- AI assisted CHANGELOG.md generation
|
||||
### Fixed
|
||||
- Fixed NuGet packing metadata by explicitly packing `LICENSE.md`, `README.md`, and `CHANGELOG.md` into the package.
|
||||
- Fixed release pipeline packaging flow to create and resolve the `.nupkg` before `dotnet nuget push`.
|
||||
- Added `/staging` to `.gitignore` to avoid committing temporary release artifacts.
|
||||
|
||||
### Security
|
||||
- Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk.
|
||||
|
||||
### Changed
|
||||
- Improved error handling in `BaseFileLogger`
|
||||
|
||||
<!--
|
||||
Template for new releases:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# MaksIT.Core Library Documentation
|
||||
|
||||
  
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Abstractions](#abstractions)
|
||||
|
||||
21
assets/badges/coverage-branches.svg
Normal file
21
assets/badges/coverage-branches.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="150" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="107.5" height="20" fill="#555"/>
|
||||
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
|
||||
<rect width="150" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
|
||||
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">49.6%</text>
|
||||
<text x="128.75" y="14" fill="#fff">49.6%</text>
|
||||
</g>
|
||||
</svg>
|
||||
21
assets/badges/coverage-lines.svg
Normal file
21
assets/badges/coverage-lines.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="134.5" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="94.5" height="20" fill="#555"/>
|
||||
<rect x="94.5" width="40" height="20" fill="#97ca00"/>
|
||||
<rect width="134.5" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
|
||||
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
|
||||
<text aria-hidden="true" x="114.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
|
||||
<text x="114.5" y="14" fill="#fff">60%</text>
|
||||
</g>
|
||||
</svg>
|
||||
21
assets/badges/coverage-methods.svg
Normal file
21
assets/badges/coverage-methods.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="150" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="107.5" height="20" fill="#555"/>
|
||||
<rect x="107.5" width="42.5" height="20" fill="#97ca00"/>
|
||||
<rect width="150" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
|
||||
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">69.2%</text>
|
||||
<text x="128.75" y="14" fill="#fff">69.2%</text>
|
||||
</g>
|
||||
</svg>
|
||||
@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@ -41,16 +41,6 @@
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Source Link package -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.103" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||
@ -64,4 +54,13 @@
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
|
||||
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
|
||||
|
||||
<None Include="..\..\assets\badges\**\*" Link="assets\badges\%(RecursiveDir)%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
|
||||
pause
|
||||
@ -1,201 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs the following operations:
|
||||
1. Gets the last commit and verifies it has an associated tag
|
||||
2. Stages all pending changes
|
||||
3. Amends the latest commit (keeps existing message)
|
||||
4. Deletes and recreates the tag on the amended commit
|
||||
5. Force pushes the branch and tag to origin
|
||||
|
||||
.PARAMETER DryRun
|
||||
If specified, shows what would be done without making changes.
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1 -DryRun
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host "`n>> $Text" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Arguments,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$CaptureOutput,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ErrorMessage = "Git command failed"
|
||||
)
|
||||
|
||||
if ($CaptureOutput) {
|
||||
$output = & git @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$ErrorMessage (exit code: $exitCode)"
|
||||
}
|
||||
return $output
|
||||
} else {
|
||||
& git @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$ErrorMessage (exit code: $exitCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "`n========================================" -ForegroundColor Magenta
|
||||
Write-Host " Force Amend Tagged Commit Script" -ForegroundColor Magenta
|
||||
Write-Host "========================================`n" -ForegroundColor Magenta
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Warn "*** DRY RUN MODE - No changes will be made ***`n"
|
||||
}
|
||||
|
||||
# Get current branch
|
||||
Write-Step "Getting current branch..."
|
||||
$Branch = Invoke-Git -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Failed to get current branch"
|
||||
Write-Info "Branch: $Branch"
|
||||
|
||||
# Get last commit info
|
||||
Write-Step "Getting last commit..."
|
||||
$null = Invoke-Git -Arguments @("rev-parse", "HEAD") -CaptureOutput -ErrorMessage "Failed to get HEAD commit"
|
||||
$CommitMessage = Invoke-Git -Arguments @("log", "-1", "--format=%s") -CaptureOutput
|
||||
$CommitHash = Invoke-Git -Arguments @("log", "-1", "--format=%h") -CaptureOutput
|
||||
Write-Info "Commit: $CommitHash - $CommitMessage"
|
||||
|
||||
# Find tag pointing to HEAD
|
||||
Write-Step "Finding tag on last commit..."
|
||||
$Tags = & git tag --points-at HEAD 2>&1
|
||||
|
||||
if (-not $Tags -or [string]::IsNullOrWhiteSpace("$Tags")) {
|
||||
throw "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
|
||||
}
|
||||
|
||||
# If multiple tags, use the first one
|
||||
$TagName = ("$Tags" -split "`n")[0].Trim()
|
||||
Write-Success "Found tag: $TagName"
|
||||
|
||||
# Show current status
|
||||
Write-Step "Checking pending changes..."
|
||||
$Status = & git status --short 2>&1
|
||||
if ($Status -and -not [string]::IsNullOrWhiteSpace("$Status")) {
|
||||
Write-Info "Pending changes:"
|
||||
"$Status" -split "`n" | ForEach-Object { Write-Info " $_" }
|
||||
} else {
|
||||
Write-Warn "No pending changes found"
|
||||
$confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# Confirm operation
|
||||
Write-Host "`n----------------------------------------" -ForegroundColor White
|
||||
Write-Host " Summary of operations:" -ForegroundColor White
|
||||
Write-Host "----------------------------------------" -ForegroundColor White
|
||||
Write-Host " Branch: $Branch" -ForegroundColor White
|
||||
Write-Host " Commit: $CommitHash" -ForegroundColor White
|
||||
Write-Host " Tag: $TagName" -ForegroundColor White
|
||||
Write-Host " Remote: origin" -ForegroundColor White
|
||||
Write-Host "----------------------------------------`n" -ForegroundColor White
|
||||
|
||||
if (-not $DryRun) {
|
||||
$confirm = Read-Host " Proceed with amend and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# Stage all changes
|
||||
Write-Step "Staging all changes..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
|
||||
}
|
||||
Write-Success "All changes staged"
|
||||
|
||||
# Amend commit
|
||||
Write-Step "Amending commit..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
|
||||
}
|
||||
Write-Success "Commit amended"
|
||||
|
||||
# Delete local tag
|
||||
Write-Step "Deleting local tag '$TagName'..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("tag", "-d", $TagName) -ErrorMessage "Failed to delete local tag"
|
||||
}
|
||||
Write-Success "Local tag deleted"
|
||||
|
||||
# Recreate tag on new commit
|
||||
Write-Step "Recreating tag '$TagName' on amended commit..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("tag", $TagName) -ErrorMessage "Failed to create tag"
|
||||
}
|
||||
Write-Success "Tag recreated"
|
||||
|
||||
# Force push branch
|
||||
Write-Step "Force pushing branch '$Branch' to origin..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("push", "--force", "origin", $Branch) -ErrorMessage "Failed to force push branch"
|
||||
}
|
||||
Write-Success "Branch force pushed"
|
||||
|
||||
# Force push tag
|
||||
Write-Step "Force pushing tag '$TagName' to origin..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("push", "--force", "origin", $TagName) -ErrorMessage "Failed to force push tag"
|
||||
}
|
||||
Write-Success "Tag force pushed"
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host " Operation completed successfully!" -ForegroundColor Green
|
||||
Write-Host "========================================`n" -ForegroundColor Green
|
||||
|
||||
# Show final state
|
||||
Write-Host "Final state:" -ForegroundColor White
|
||||
& git log -1 --oneline
|
||||
Write-Host ""
|
||||
|
||||
} catch {
|
||||
Write-Host "`n========================================" -ForegroundColor Red
|
||||
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "========================================`n" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
@echo off
|
||||
|
||||
REM Change directory to the location of the script
|
||||
cd /d %~dp0
|
||||
|
||||
REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,47 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$comment": "Configuration for Release-NuGetPackage.ps1. Secrets are stored in environment variables, not here.",
|
||||
|
||||
"release": {
|
||||
"branch": "main",
|
||||
"$comment": "Tag must be on this branch to release. Set to empty string to allow any branch."
|
||||
},
|
||||
|
||||
"paths": {
|
||||
"changelogPath": "../../CHANGELOG.md"
|
||||
},
|
||||
|
||||
"gitHub": {
|
||||
"enabled": true,
|
||||
"repository": "MAKS-IT-COM/maksit-core",
|
||||
"$comment": "Explicit GitHub repository (owner/repo). If empty, auto-detects from git remote."
|
||||
},
|
||||
|
||||
"environmentVariables": {
|
||||
"$comment": "Required environment variables (store secrets here, not in this file)",
|
||||
"nugetApiKey": "NUGET_MAKS_IT",
|
||||
"githubToken": "GITHUB_MAKS_IT_COM",
|
||||
"signingCertPassword": "SIGNING_CERT_PASSWORD",
|
||||
"smtpPassword": "SMTP_PASSWORD"
|
||||
},
|
||||
|
||||
"qualityGates": {
|
||||
"coverageThreshold": 0,
|
||||
"failOnVulnerabilities": true
|
||||
},
|
||||
|
||||
"packageSigning": {
|
||||
"enabled": false,
|
||||
"certificatePath": "",
|
||||
"timestampServer": "http://timestamp.digicert.com"
|
||||
},
|
||||
|
||||
"emailNotification": {
|
||||
"enabled": false,
|
||||
"smtpServer": "",
|
||||
"smtpPort": 587,
|
||||
"useSsl": true,
|
||||
"from": "",
|
||||
"to": ""
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
pause
|
||||
220
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
220
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
@ -0,0 +1,220 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs the following operations:
|
||||
1. Gets the last commit and verifies it has an associated tag
|
||||
2. Stages all pending changes
|
||||
3. Amends the latest commit (keeps existing message)
|
||||
4. Deletes and recreates the tag on the amended commit
|
||||
5. Force pushes the branch and tag to remote
|
||||
|
||||
All configuration is in scriptsettings.json.
|
||||
|
||||
.PARAMETER DryRun
|
||||
If specified, shows what would be done without making changes.
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1 -DryRun
|
||||
|
||||
.NOTES
|
||||
CONFIGURATION (scriptsettings.json):
|
||||
- git.remote: Remote name to push to (default: "origin")
|
||||
- git.confirmBeforeAmend: Prompt before amending (default: true)
|
||||
- git.confirmWhenNoChanges: Prompt if no pending changes (default: true)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
#region Import Modules
|
||||
|
||||
# Import shared ScriptConfig module (settings loading + dependency checks)
|
||||
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
|
||||
if (-not (Test-Path $scriptConfigModulePath)) {
|
||||
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Import shared GitTools module (git operations used by this script)
|
||||
$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
|
||||
if (-not (Test-Path $gitToolsModulePath)) {
|
||||
Write-Error "GitTools module not found at: $gitToolsModulePath"
|
||||
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
|
||||
Import-Module $gitToolsModulePath -Force
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
|
||||
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
|
||||
# Git configuration with safe defaults when settings are omitted
|
||||
$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" }
|
||||
$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true }
|
||||
$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate CLI Dependencies
|
||||
|
||||
Assert-Command git
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
Write-Log -Level "INFO" -Message "========================================"
|
||||
Write-Log -Level "INFO" -Message "Force Amend Tagged Commit Script"
|
||||
Write-Log -Level "INFO" -Message "========================================"
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Log -Level "WARN" -Message "*** DRY RUN MODE - No changes will be made ***"
|
||||
}
|
||||
|
||||
#region Preflight
|
||||
|
||||
# 1. Detect current branch
|
||||
$Branch = Get-CurrentBranch
|
||||
|
||||
# 2. Read HEAD commit details
|
||||
Write-LogStep "Getting last commit..."
|
||||
$CommitMessage = Get-HeadCommitMessage
|
||||
$CommitHash = Get-HeadCommitHash -Short
|
||||
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
|
||||
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]
|
||||
Write-Log -Level "OK" -Message "Found tag: $TagName"
|
||||
|
||||
# 4. Inspect pending changes before amend
|
||||
Write-LogStep "Checking pending changes..."
|
||||
$Status = Get-GitStatusShort
|
||||
if (-not [string]::IsNullOrWhiteSpace($Status)) {
|
||||
Write-Log -Level "INFO" -Message "Pending changes:"
|
||||
$Status -split "`r?`n" | ForEach-Object { Write-Log -Level "INFO" -Message " $_" }
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message "No pending changes found"
|
||||
if ($ConfirmWhenNoChanges -and -not $DryRun) {
|
||||
$confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Log -Level "WARN" -Message "Aborted by user"
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 5. Show operation summary and request explicit confirmation
|
||||
Write-Log -Level "INFO" -Message "----------------------------------------"
|
||||
Write-Log -Level "INFO" -Message "Summary of operations:"
|
||||
Write-Log -Level "INFO" -Message "----------------------------------------"
|
||||
Write-Log -Level "INFO" -Message "Branch: $Branch"
|
||||
Write-Log -Level "INFO" -Message "Commit: $CommitHash"
|
||||
Write-Log -Level "INFO" -Message "Tag: $TagName"
|
||||
Write-Log -Level "INFO" -Message "Remote: $Remote"
|
||||
Write-Log -Level "INFO" -Message "----------------------------------------"
|
||||
|
||||
if ($ConfirmBeforeAmend -and -not $DryRun) {
|
||||
$confirm = Read-Host " Proceed with amend and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Log -Level "WARN" -Message "Aborted by user"
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Amend And Push
|
||||
|
||||
# 6. Stage all changes to include them in amended commit
|
||||
Write-LogStep "Staging all changes..."
|
||||
if (-not $DryRun) {
|
||||
Add-AllChanges
|
||||
}
|
||||
Write-Log -Level "OK" -Message "All changes staged"
|
||||
|
||||
# 7. Amend HEAD commit while preserving commit message
|
||||
Write-LogStep "Amending commit..."
|
||||
if (-not $DryRun) {
|
||||
Update-HeadCommitNoEdit
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Commit amended"
|
||||
|
||||
# 8. Move existing local tag to the amended commit
|
||||
Write-LogStep "Deleting local tag '$TagName'..."
|
||||
if (-not $DryRun) {
|
||||
Remove-LocalTag -Tag $TagName
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Local tag deleted"
|
||||
|
||||
# 9. Recreate the same tag on new HEAD
|
||||
Write-LogStep "Recreating tag '$TagName' on amended commit..."
|
||||
if (-not $DryRun) {
|
||||
New-LocalTag -Tag $TagName
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Tag recreated"
|
||||
|
||||
# 10. Force push updated branch history
|
||||
Write-LogStep "Force pushing branch '$Branch' to $Remote..."
|
||||
if (-not $DryRun) {
|
||||
Push-BranchToRemote -Branch $Branch -Remote $Remote -Force
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Branch force pushed"
|
||||
|
||||
# 11. Force push moved tag
|
||||
Write-LogStep "Force pushing tag '$TagName' to $Remote..."
|
||||
if (-not $DryRun) {
|
||||
Push-TagToRemote -Tag $TagName -Remote $Remote -Force
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Tag force pushed"
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
|
||||
Write-Log -Level "OK" -Message "========================================"
|
||||
Write-Log -Level "OK" -Message "Operation completed successfully!"
|
||||
Write-Log -Level "OK" -Message "========================================"
|
||||
|
||||
# Show resulting HEAD commit after amend
|
||||
Write-Log -Level "INFO" -Message "Final state:"
|
||||
$finalLog = Get-HeadCommitOneLine
|
||||
Write-Log -Level "INFO" -Message $finalLog
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$comment": "Configuration for Force-AmendTaggedCommit.ps1",
|
||||
|
||||
"git": {
|
||||
"remote": "origin",
|
||||
"confirmBeforeAmend": true,
|
||||
"confirmWhenNoChanges": true
|
||||
},
|
||||
|
||||
"_comments": {
|
||||
"git": {
|
||||
"remote": "Remote name used for force-pushing branch and tag",
|
||||
"confirmBeforeAmend": "Ask for confirmation before amend + force-push operations",
|
||||
"confirmWhenNoChanges": "Ask for confirmation when there are no pending changes to amend"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||
pause
|
||||
231
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
231
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
@ -0,0 +1,231 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
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.
|
||||
|
||||
Configuration is stored in scriptsettings.json:
|
||||
- openReport : Generate and open full HTML report (true/false)
|
||||
- paths.testProject : Relative path to test project
|
||||
- paths.badgesDir : Relative path to badges output directory
|
||||
- badges : Array of badges to generate (name, label, metric)
|
||||
- colorThresholds : Coverage percentages for badge colors
|
||||
|
||||
Badge colors based on coverage:
|
||||
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
|
||||
- yellow (>=20%), orange (>=10%), red (<10%)
|
||||
If openReport is true, ReportGenerator is required:
|
||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||
|
||||
.EXAMPLE
|
||||
.\Generate-CoverageBadges.ps1
|
||||
Runs tests and generates coverage badges (and optionally HTML report if configured).
|
||||
|
||||
.OUTPUTS
|
||||
SVG badge files in the configured badges directory.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
Requires: .NET SDK, Coverlet (included in test project)
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Get the directory of the current script (for loading settings and relative paths)
|
||||
$ScriptDir = $PSScriptRoot
|
||||
$UtilsDir = Split-Path $ScriptDir -Parent
|
||||
|
||||
#region Import Modules
|
||||
|
||||
# Import TestRunner module (executes tests and collects coverage metrics)
|
||||
$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 shared ScriptConfig module (settings + command validation helpers)
|
||||
$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 shared Logging module (timestamped/aligned output)
|
||||
$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
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
|
||||
$Settings = Get-ScriptSettings -ScriptDir $ScriptDir
|
||||
|
||||
$thresholds = $Settings.colorThresholds
|
||||
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
|
||||
# Runtime options from settings
|
||||
$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false }
|
||||
|
||||
# Resolve configured paths to absolute paths
|
||||
$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject))
|
||||
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
|
||||
|
||||
# Ensure badges directory exists
|
||||
if (-not (Test-Path $BadgesDir)) {
|
||||
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
# Maps a coverage percentage to a shields.io color using configured thresholds.
|
||||
function Get-BadgeColor {
|
||||
param([double]$percentage)
|
||||
|
||||
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
|
||||
if ($percentage -ge $thresholds.green) { return "green" }
|
||||
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
|
||||
if ($percentage -ge $thresholds.yellow) { return "yellow" }
|
||||
if ($percentage -ge $thresholds.orange) { return "orange" }
|
||||
return "red"
|
||||
}
|
||||
|
||||
# Builds a shields.io-like SVG badge string for one metric.
|
||||
function New-Badge {
|
||||
param(
|
||||
[string]$label,
|
||||
[string]$value,
|
||||
[string]$color
|
||||
)
|
||||
|
||||
# Calculate widths (approximate character width of 6.5px for the font)
|
||||
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
|
||||
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
|
||||
$totalWidth = $labelWidth + $valueWidth
|
||||
$labelX = $labelWidth / 2
|
||||
$valueX = $labelWidth + ($valueWidth / 2)
|
||||
|
||||
$colorMap = @{
|
||||
"brightgreen" = "#4c1"
|
||||
"green" = "#97ca00"
|
||||
"yellowgreen" = "#a4a61d"
|
||||
"yellow" = "#dfb317"
|
||||
"orange" = "#fe7d37"
|
||||
"red" = "#e05d44"
|
||||
}
|
||||
$hexColor = $colorMap[$color]
|
||||
if (-not $hexColor) { $hexColor = "#9f9f9f" }
|
||||
|
||||
return @"
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
|
||||
<title>$label`: $value</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="$labelWidth" height="20" fill="#555"/>
|
||||
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
|
||||
<rect width="$totalWidth" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
|
||||
<text x="$labelX" y="14" fill="#fff">$label</text>
|
||||
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
|
||||
<text x="$valueX" y="14" fill="#fff">$value</text>
|
||||
</g>
|
||||
</svg>
|
||||
"@
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
#region Test And Coverage
|
||||
|
||||
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
|
||||
if (-not $coverage.Success) {
|
||||
Write-Error "Tests failed: $($coverage.Error)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "Tests passed!"
|
||||
|
||||
$metrics = @{
|
||||
"line" = $coverage.LineRate
|
||||
"branch" = $coverage.BranchRate
|
||||
"method" = $coverage.MethodRate
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generate Badges
|
||||
|
||||
Write-LogStep -Message "Generating coverage badges..."
|
||||
|
||||
foreach ($badge in $Settings.badges) {
|
||||
$metricValue = $metrics[$badge.metric]
|
||||
$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
|
||||
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
|
||||
Write-Log -Level "INFO" -Message "Coverage Summary:"
|
||||
Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%"
|
||||
Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%"
|
||||
Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
|
||||
Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir"
|
||||
Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README."
|
||||
|
||||
#endregion
|
||||
|
||||
#region Optional Html Report
|
||||
|
||||
if ($OpenReport -and $coverage.CoverageFile) {
|
||||
Write-LogStep -Message "Generating HTML report..."
|
||||
Assert-Command reportgenerator
|
||||
|
||||
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
|
||||
$ReportDir = Join-Path $ResultsDir "report"
|
||||
|
||||
$reportGenArgs = @(
|
||||
"-reports:$($coverage.CoverageFile)"
|
||||
"-targetdir:$ReportDir"
|
||||
"-reporttypes:Html"
|
||||
)
|
||||
& reportgenerator @reportGenArgs
|
||||
|
||||
$IndexFile = Join-Path $ReportDir "index.html"
|
||||
if (Test-Path $IndexFile) {
|
||||
Start-Process $IndexFile
|
||||
}
|
||||
|
||||
Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing."
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Generate Coverage Badges Script Settings",
|
||||
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
||||
"openReport": false,
|
||||
"paths": {
|
||||
"testProject": "..\\..\\src\\MaksIT.Core.Tests",
|
||||
"badgesDir": "..\\..\\assets\\badges"
|
||||
},
|
||||
"badges": [
|
||||
{
|
||||
"name": "coverage-lines.svg",
|
||||
"label": "Line Coverage",
|
||||
"metric": "line"
|
||||
},
|
||||
{
|
||||
"name": "coverage-branches.svg",
|
||||
"label": "Branch Coverage",
|
||||
"metric": "branch"
|
||||
},
|
||||
{
|
||||
"name": "coverage-methods.svg",
|
||||
"label": "Method Coverage",
|
||||
"metric": "method"
|
||||
}
|
||||
],
|
||||
"colorThresholds": {
|
||||
"brightgreen": 80,
|
||||
"green": 60,
|
||||
"yellowgreen": 40,
|
||||
"yellow": 20,
|
||||
"orange": 10,
|
||||
"red": 0
|
||||
},
|
||||
"_comments": {
|
||||
"openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).",
|
||||
"paths": {
|
||||
"testProject": "Relative path to test project used by TestRunner.",
|
||||
"badgesDir": "Relative path where SVG coverage badges are written."
|
||||
},
|
||||
"badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.",
|
||||
"colorThresholds": "Coverage percentage thresholds used to pick badge colors."
|
||||
}
|
||||
}
|
||||
265
utils/GitTools.psm1
Normal file
265
utils/GitTools.psm1
Normal file
@ -0,0 +1,265 @@
|
||||
#
|
||||
# Shared Git helpers for utility scripts.
|
||||
#
|
||||
|
||||
function Import-LoggingModuleInternal {
|
||||
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||
return
|
||||
}
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot "Logging.psm1"
|
||||
if (Test-Path $modulePath) {
|
||||
Import-Module $modulePath -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Write-GitToolsLogInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||
[string]$Level = "INFO"
|
||||
)
|
||||
|
||||
Import-LoggingModuleInternal
|
||||
|
||||
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||
Write-Log -Level $Level -Message $Message
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host $Message -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Internal:
|
||||
# Purpose:
|
||||
# - Execute a git command and enforce fail-fast error handling.
|
||||
function Invoke-GitInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Arguments,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$CaptureOutput,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ErrorMessage = "Git command failed"
|
||||
)
|
||||
|
||||
if ($CaptureOutput) {
|
||||
$output = & git @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Error "$ErrorMessage (exit code: $exitCode)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($null -eq $output) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ($output -join "`n").Trim()
|
||||
}
|
||||
|
||||
& git @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Error "$ErrorMessage (exit code: $exitCode)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Resolve and print the current branch name.
|
||||
function Get-CurrentBranch {
|
||||
Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..."
|
||||
|
||||
$branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch"
|
||||
Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch"
|
||||
return $branch
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Return `git status --short` output for pending-change checks.
|
||||
function Get-GitStatusShort {
|
||||
return Invoke-GitInternal -Arguments @("status", "--short") -CaptureOutput -ErrorMessage "Failed to get git status"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||
# Purpose:
|
||||
# - Get exact tag name attached to HEAD (release flow).
|
||||
function Get-CurrentCommitTag {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
Write-GitToolsLogInternal -Level "STEP" -Message "Checking for tag on current commit..."
|
||||
$tag = Invoke-GitInternal -Arguments @("describe", "--tags", "--exact-match", "HEAD") -CaptureOutput -ErrorMessage "No tag found on current commit. Create a tag: git tag v$Version"
|
||||
return $tag
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Get all tag names pointing at HEAD.
|
||||
function Get-HeadTags {
|
||||
$tagsRaw = Invoke-GitInternal -Arguments @("tag", "--points-at", "HEAD") -CaptureOutput -ErrorMessage "Failed to list tags on HEAD"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($tagsRaw)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
return @($tagsRaw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||
# Purpose:
|
||||
# - Check whether a given tag exists on the remote.
|
||||
function Test-RemoteTagExists {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Tag,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Remote = "origin"
|
||||
)
|
||||
|
||||
$remoteTag = Invoke-GitInternal -Arguments @("ls-remote", "--tags", $Remote, $Tag) -CaptureOutput -ErrorMessage "Failed to check remote tag existence"
|
||||
return [bool]$remoteTag
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Push tag to remote (optionally with `--force`).
|
||||
function Push-TagToRemote {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Tag,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Remote = "origin",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$pushArgs = @("push")
|
||||
if ($Force) {
|
||||
$pushArgs += "--force"
|
||||
}
|
||||
$pushArgs += @($Remote, $Tag)
|
||||
|
||||
Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push tag $Tag to remote $Remote"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Push branch to remote (optionally with `--force`).
|
||||
function Push-BranchToRemote {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Branch,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Remote = "origin",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$pushArgs = @("push")
|
||||
if ($Force) {
|
||||
$pushArgs += "--force"
|
||||
}
|
||||
$pushArgs += @($Remote, $Branch)
|
||||
|
||||
Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push branch $Branch to remote $Remote"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Get HEAD commit hash.
|
||||
function Get-HeadCommitHash {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Short
|
||||
)
|
||||
|
||||
$format = if ($Short) { "--format=%h" } else { "--format=%H" }
|
||||
return Invoke-GitInternal -Arguments @("log", "-1", $format) -CaptureOutput -ErrorMessage "Failed to get HEAD commit hash"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Get HEAD commit subject line.
|
||||
function Get-HeadCommitMessage {
|
||||
return Invoke-GitInternal -Arguments @("log", "-1", "--format=%s") -CaptureOutput -ErrorMessage "Failed to get HEAD commit message"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Stage all changes (tracked, untracked, deletions).
|
||||
function Add-AllChanges {
|
||||
Invoke-GitInternal -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Amend HEAD commit and keep existing commit message.
|
||||
function Update-HeadCommitNoEdit {
|
||||
Invoke-GitInternal -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Delete local tag.
|
||||
function Remove-LocalTag {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Tag
|
||||
)
|
||||
|
||||
Invoke-GitInternal -Arguments @("tag", "-d", $Tag) -ErrorMessage "Failed to delete local tag"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Create local tag.
|
||||
function New-LocalTag {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Tag
|
||||
)
|
||||
|
||||
Invoke-GitInternal -Arguments @("tag", $Tag) -ErrorMessage "Failed to create tag"
|
||||
}
|
||||
|
||||
# Used by:
|
||||
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||
# Purpose:
|
||||
# - Get HEAD one-line commit info.
|
||||
function Get-HeadCommitOneLine {
|
||||
return Invoke-GitInternal -Arguments @("log", "-1", "--oneline") -CaptureOutput -ErrorMessage "Failed to read final commit state"
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-CurrentBranch, Get-GitStatusShort, Get-CurrentCommitTag, Get-HeadTags, Test-RemoteTagExists, Push-TagToRemote, Push-BranchToRemote, Get-HeadCommitHash, Get-HeadCommitMessage, Add-AllChanges, Update-HeadCommitNoEdit, Remove-LocalTag, New-LocalTag, Get-HeadCommitOneLine
|
||||
67
utils/Logging.psm1
Normal file
67
utils/Logging.psm1
Normal file
@ -0,0 +1,67 @@
|
||||
function Get-LogTimestampInternal {
|
||||
return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
function Get-LogColorInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Level
|
||||
)
|
||||
|
||||
switch ($Level.ToUpperInvariant()) {
|
||||
"OK" { return "Green" }
|
||||
"INFO" { return "Gray" }
|
||||
"WARN" { return "Yellow" }
|
||||
"ERROR" { return "Red" }
|
||||
"STEP" { return "Cyan" }
|
||||
"DEBUG" { return "DarkGray" }
|
||||
default { return "White" }
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Log {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||
[string]$Level = "INFO",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$NoTimestamp
|
||||
)
|
||||
|
||||
$levelToken = "[$($Level.ToUpperInvariant())]"
|
||||
$padding = " " * [Math]::Max(1, (10 - $levelToken.Length))
|
||||
$prefix = if ($NoTimestamp) { "" } else { "[$(Get-LogTimestampInternal)] " }
|
||||
$line = "$prefix$levelToken$padding$Message"
|
||||
|
||||
Write-Host $line -ForegroundColor (Get-LogColorInternal -Level $Level)
|
||||
}
|
||||
|
||||
function Write-LogStep {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
Write-Log -Level "STEP" -Message $Message
|
||||
}
|
||||
|
||||
function Write-LogStepResult {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("OK", "FAIL")]
|
||||
[string]$Status,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
$level = if ($Status -eq "FAIL") { "ERROR" } else { "OK" }
|
||||
$text = if ([string]::IsNullOrWhiteSpace($Message)) { $Status } else { $Message }
|
||||
Write-Log -Level $level -Message $text
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Write-Log, Write-LogStep, Write-LogStepResult
|
||||
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||
pause
|
||||
747
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
747
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
@ -0,0 +1,747 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Release script for MaksIT.Core NuGet package and GitHub release.
|
||||
|
||||
.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.
|
||||
|
||||
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:
|
||||
- qualityGates: Coverage threshold, vulnerability checks
|
||||
- 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)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
#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. Get repository info
|
||||
$remoteUrl = git config --get remote.origin.url
|
||||
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
|
||||
Write-Error "Could not determine git remote origin URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||
$owner = $matches['owner']
|
||||
$repoName = $matches['repo']
|
||||
$repo = "$owner/$repoName"
|
||||
} else {
|
||||
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$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
|
||||
|
||||
$ghApiAuthArgs = @(
|
||||
"-H", "Authorization: token $githubToken"
|
||||
)
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
$authArgs = @("api", "user") + $ghApiAuthArgs
|
||||
$authTest = & gh @authArgs 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..."
|
||||
|
||||
# gh release subcommands do not support custom auth headers.
|
||||
# Scope GH_TOKEN to this block so commands authenticate with the configured token.
|
||||
$previousGhToken = $env:GH_TOKEN
|
||||
$env:GH_TOKEN = $githubToken
|
||||
|
||||
try {
|
||||
# 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
|
||||
75
utils/Release-NuGetPackage/scriptsettings.json
Normal file
75
utils/Release-NuGetPackage/scriptsettings.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
|
||||
"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"
|
||||
},
|
||||
|
||||
"qualityGates": {
|
||||
"coverageThreshold": 0,
|
||||
"failOnVulnerabilities": true
|
||||
},
|
||||
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"qualityGates": {
|
||||
"coverageThreshold": "Coverage threshold percent for quality gate (0 disables threshold check).",
|
||||
"failOnVulnerabilities": "If true, fail when vulnerable packages are detected."
|
||||
},
|
||||
"release": {
|
||||
"zipNamePattern": "Archive name pattern. Supports {version} placeholder.",
|
||||
"releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder."
|
||||
}
|
||||
}
|
||||
}
|
||||
32
utils/ScriptConfig.psm1
Normal file
32
utils/ScriptConfig.psm1
Normal file
@ -0,0 +1,32 @@
|
||||
function Get-ScriptSettings {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptDir,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$SettingsFileName = "scriptsettings.json"
|
||||
)
|
||||
|
||||
$settingsPath = Join-Path $ScriptDir $SettingsFileName
|
||||
|
||||
if (-not (Test-Path $settingsPath -PathType Leaf)) {
|
||||
Write-Error "Settings file not found: $settingsPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
return Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Command
|
||||
)
|
||||
|
||||
if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Required command '$Command' is missing. Aborting."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ScriptSettings, Assert-Command
|
||||
199
utils/TestRunner.psm1
Normal file
199
utils/TestRunner.psm1
Normal file
@ -0,0 +1,199 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
PowerShell module for running tests with code coverage.
|
||||
|
||||
.DESCRIPTION
|
||||
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||
with Coverlet code coverage collection and parsing results.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
Usage: Import-Module .\TestRunner.psm1
|
||||
#>
|
||||
|
||||
function Import-LoggingModuleInternal {
|
||||
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||
return
|
||||
}
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot "Logging.psm1"
|
||||
if (Test-Path $modulePath) {
|
||||
Import-Module $modulePath -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Write-TestRunnerLogInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||
[string]$Level = "INFO"
|
||||
)
|
||||
|
||||
Import-LoggingModuleInternal
|
||||
|
||||
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||
Write-Log -Level $Level -Message $Message
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host $Message -ForegroundColor Gray
|
||||
}
|
||||
|
||||
function Invoke-TestsWithCoverage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs unit tests with code coverage and returns coverage metrics.
|
||||
|
||||
.PARAMETER TestProjectPath
|
||||
Path to the test project directory.
|
||||
|
||||
.PARAMETER Silent
|
||||
Suppress console output (for JSON consumption).
|
||||
|
||||
.PARAMETER ResultsDirectory
|
||||
Optional fixed directory where test result files are written.
|
||||
|
||||
.PARAMETER KeepResults
|
||||
Keep the TestResults folder after execution.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with properties:
|
||||
- Success: bool
|
||||
- Error: string (if failed)
|
||||
- LineRate: double
|
||||
- BranchRate: double
|
||||
- MethodRate: double
|
||||
- TotalMethods: int
|
||||
- CoveredMethods: int
|
||||
- CoverageFile: string
|
||||
|
||||
.EXAMPLE
|
||||
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
|
||||
if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" }
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TestProjectPath,
|
||||
|
||||
[switch]$Silent,
|
||||
|
||||
[string]$ResultsDirectory,
|
||||
|
||||
[switch]$KeepResults
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve path
|
||||
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
|
||||
if (-not $TestProjectDir) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Test project not found at: $TestProjectPath"
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
|
||||
$ResultsDir = Join-Path $TestProjectDir "TestResults"
|
||||
}
|
||||
else {
|
||||
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
|
||||
}
|
||||
|
||||
# Clean previous results
|
||||
if (Test-Path $ResultsDir) {
|
||||
Remove-Item -Recurse -Force $ResultsDir
|
||||
}
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
|
||||
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir"
|
||||
}
|
||||
|
||||
# Run tests with coverage collection
|
||||
Push-Location $TestProjectDir
|
||||
try {
|
||||
$dotnetArgs = @(
|
||||
"test"
|
||||
"--collect:XPlat Code Coverage"
|
||||
"--results-directory", $ResultsDir
|
||||
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||
)
|
||||
|
||||
if ($Silent) {
|
||||
$null = & dotnet @dotnetArgs 2>&1
|
||||
} else {
|
||||
& dotnet @dotnetArgs
|
||||
}
|
||||
|
||||
$testExitCode = $LASTEXITCODE
|
||||
if ($testExitCode -ne 0) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Tests failed with exit code $testExitCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Find the coverage file
|
||||
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
|
||||
|
||||
if (-not $CoverageFile) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Coverage file not found"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)"
|
||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
|
||||
}
|
||||
|
||||
# Parse coverage data from Cobertura XML
|
||||
[xml]$coverageXml = Get-Content $CoverageFile.FullName
|
||||
|
||||
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
|
||||
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
|
||||
|
||||
# Calculate method coverage from packages
|
||||
$totalMethods = 0
|
||||
$coveredMethods = 0
|
||||
foreach ($package in $coverageXml.coverage.packages.package) {
|
||||
foreach ($class in $package.classes.class) {
|
||||
foreach ($method in $class.methods.method) {
|
||||
$totalMethods++
|
||||
if ([double]$method.'line-rate' -gt 0) {
|
||||
$coveredMethods++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
|
||||
|
||||
# Cleanup unless KeepResults is specified
|
||||
if (-not $KeepResults) {
|
||||
if (Test-Path $ResultsDir) {
|
||||
Remove-Item -Recurse -Force $ResultsDir
|
||||
}
|
||||
}
|
||||
|
||||
# Return results
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
LineRate = $lineRate
|
||||
BranchRate = $branchRate
|
||||
MethodRate = $methodRate
|
||||
TotalMethods = $totalMethods
|
||||
CoveredMethods = $coveredMethods
|
||||
CoverageFile = $CoverageFile.FullName
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-TestsWithCoverage
|
||||
Loading…
Reference in New Issue
Block a user