(feature): centralize release tooling, logging, and package/release flow
This commit is contained in:
parent
b8f6617da6
commit
be2169a54a
3
.gitignore
vendored
3
.gitignore
vendored
@ -260,3 +260,6 @@ paket-files/
|
|||||||
# Python Tools for Visual Studio (PTVS)
|
# Python Tools for Visual Studio (PTVS)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## v1.6.3 - 2026-02-13
|
## v1.6.4 - 2026-02-21
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Updated dependencies to latest versions for improved performance and security.
|
|
||||||
|
|
||||||
## v1.6.2 - 2026-02-13
|
|
||||||
|
|
||||||
### Added
|
### 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
|
### Changed
|
||||||
- Improved `BaseFileLogger` to ensure log folder is recreated if deleted during runtime (idempotent folder creation).
|
- Refactored release/amend/badges scripts to a modular structure with shared modules.
|
||||||
- Added comprehensive tests verifying log folder recreation and robustness against folder deletion scenarios.
|
- Standardized script structure with regions and clearer comments.
|
||||||
- Removed AI assisted CHANGELOG.md generation as it's weak and not worth the effort.
|
- 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
|
### Fixed
|
||||||
- Added `CreateMutex` method to `BaseFileLogger`
|
- Fixed NuGet packing metadata by explicitly packing `LICENSE.md`, `README.md`, and `CHANGELOG.md` into the package.
|
||||||
- Added `ResolveFolderPath` and `SanitizeForPath` methods to `FileLoggerProvider`
|
- Fixed release pipeline packaging flow to create and resolve the `.nupkg` before `dotnet nuget push`.
|
||||||
- Added `ResolveFolderPath` and `SanitizeForPath` methods to `JsonFileLoggerProvider`
|
- Added `/staging` to `.gitignore` to avoid committing temporary release artifacts.
|
||||||
- Added `LoggerPrefix` class for managing logger prefixes
|
|
||||||
- AI assisted CHANGELOG.md generation
|
### 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:
|
Template for new releases:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# MaksIT.Core Library Documentation
|
# MaksIT.Core Library Documentation
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Abstractions](#abstractions)
|
- [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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Core</PackageId>
|
<PackageId>MaksIT.Core</PackageId>
|
||||||
<Version>1.6.3</Version>
|
<Version>1.6.4</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Core</Product>
|
<Product>MaksIT.Core</Product>
|
||||||
@ -41,16 +41,6 @@
|
|||||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||||
</PropertyGroup>
|
</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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||||
@ -64,4 +54,13 @@
|
|||||||
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.3" />
|
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.3" />
|
||||||
</ItemGroup>
|
</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>
|
</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