diff --git a/.gitignore b/.gitignore
index 7fa7210..ce9d491 100644
--- a/.gitignore
+++ b/.gitignore
@@ -260,3 +260,6 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
+
+
+/staging
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44d5cc8..4e1c93a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,32 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## v1.6.3 - 2026-02-13
-
-### Changed
-- Updated dependencies to latest versions for improved performance and security.
-
-## v1.6.2 - 2026-02-13
+## v1.6.4 - 2026-02-21
### Added
-- `BaseFileLogger` idempotent log folder creation and tests
+- New shared utility modules under `utils/`:
+- `Logging.psm1` for timestamped, aligned log output.
+- `ScriptConfig.psm1` for shared settings loading and command assertions.
+- `GitTools.psm1` for reusable git operations.
+- `TestRunner.psm1` for shared test/coverage execution.
+- New `Generate-CoverageBadges` utility script and settings to generate SVG coverage badges.
### Changed
-- Improved `BaseFileLogger` to ensure log folder is recreated if deleted during runtime (idempotent folder creation).
-- Added comprehensive tests verifying log folder recreation and robustness against folder deletion scenarios.
-- Removed AI assisted CHANGELOG.md generation as it's weak and not worth the effort.
+- Refactored release/amend/badges scripts to a modular structure with shared modules.
+- Standardized script structure with regions and clearer comments.
+- Switched script output to centralized logging format with timestamps (where logging module is available).
+- Updated release settings comments (`_comments`) for clarity and accuracy.
+- Updated `README.md` to show coverage badges.
-## v1.6.1 - 2026-31-01
+### Removed
+- Removed legacy scripts from `src/scripts/` in favor of the `utils/`-based toolchain.
+- Removed unused helper logic (including obsolete step-wrapper usage and unused csproj helper).
-### Added
-- Added `CreateMutex` method to `BaseFileLogger`
-- Added `ResolveFolderPath` and `SanitizeForPath` methods to `FileLoggerProvider`
-- Added `ResolveFolderPath` and `SanitizeForPath` methods to `JsonFileLoggerProvider`
-- Added `LoggerPrefix` class for managing logger prefixes
-- AI assisted CHANGELOG.md generation
+### Fixed
+- Fixed NuGet packing metadata by explicitly packing `LICENSE.md`, `README.md`, and `CHANGELOG.md` into the package.
+- Fixed release pipeline packaging flow to create and resolve the `.nupkg` before `dotnet nuget push`.
+- Added `/staging` to `.gitignore` to avoid committing temporary release artifacts.
+
+### Security
+- Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk.
-### Changed
-- Improved error handling in `BaseFileLogger`
MaksIT.Core
- 1.6.3
+ 1.6.4
Maksym Sadovnychyy
MAKS-IT
MaksIT.Core
@@ -41,16 +41,6 @@
true
-
-
-
-
-
-
-
-
-
-
@@ -64,4 +54,13 @@
+
+
+
+
+
+
+ PreserveNewest
+
+
diff --git a/src/scripts/BuildUtils.psm1 b/src/scripts/BuildUtils.psm1
deleted file mode 100644
index 835619f..0000000
--- a/src/scripts/BuildUtils.psm1
+++ /dev/null
@@ -1,1054 +0,0 @@
-<#
-.SYNOPSIS
- Build utilities module for PowerShell scripts.
-
-.DESCRIPTION
- Provides reusable functions for build/release scripts:
- - Step timing and progress tracking
- - Prerequisite/command validation
- - Git status utilities
- - Console output helpers
-
-.USAGE
- Import-Module .\BuildUtils.psm1
-
- Initialize-StepTimer
- Start-Step "Building project"
- # ... do work ...
- Complete-Step "OK"
- Show-TimingSummary
-#>
-
-# ==============================================================================
-# MODULE STATE
-# ==============================================================================
-
-$script:StepTimerState = @{
- TotalStopwatch = $null
- CurrentStep = $null
- StepTimings = @()
-}
-
-# ==============================================================================
-# STEP TIMING FUNCTIONS
-# ==============================================================================
-
-function Initialize-StepTimer {
- <#
- .SYNOPSIS
- Initialize the step timer. Call at the start of your script.
- #>
- $script:StepTimerState.TotalStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
- $script:StepTimerState.StepTimings = @()
- $script:StepTimerState.CurrentStep = $null
-}
-
-function Start-Step {
- <#
- .SYNOPSIS
- Start timing a new step with console output.
- .PARAMETER Name
- Name/description of the step.
- #>
- param([Parameter(Mandatory)][string]$Name)
-
- $script:StepTimerState.CurrentStep = @{
- Name = $Name
- Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
- }
- Write-Host ""
- Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] $Name..." -ForegroundColor Cyan
-}
-
-function Complete-Step {
- <#
- .SYNOPSIS
- Complete the current step and record timing.
- .PARAMETER Status
- Status of the step: OK, SKIP, FAIL, WARN
- #>
- param([string]$Status = "OK")
-
- $step = $script:StepTimerState.CurrentStep
- if ($step) {
- $step.Stopwatch.Stop()
- $elapsed = $step.Stopwatch.Elapsed
- $script:StepTimerState.StepTimings += @{
- Name = $step.Name
- Duration = $elapsed
- Status = $Status
- }
-
- $timeStr = "{0:mm\:ss\.fff}" -f $elapsed
- $color = switch ($Status) {
- "OK" { "Green" }
- "SKIP" { "Yellow" }
- "WARN" { "Yellow" }
- default { "Red" }
- }
-
- if ($Status -eq "SKIP") {
- Write-Host " Skipped" -ForegroundColor $color
- }
- else {
- $prefix = if ($Status -eq "OK") { "Completed" } else { "Failed" }
- Write-Host " $prefix in $timeStr" -ForegroundColor $color
- }
- }
-}
-
-function Get-StepTimings {
- <#
- .SYNOPSIS
- Get the recorded step timings.
- .OUTPUTS
- Array of step timing objects.
- #>
- return $script:StepTimerState.StepTimings
-}
-
-function Show-TimingSummary {
- <#
- .SYNOPSIS
- Display a summary of all step timings.
- #>
- Write-Host ""
- Write-Host "=========================================="
- Write-Host "TIMING SUMMARY"
- Write-Host "=========================================="
-
- foreach ($step in $script:StepTimerState.StepTimings) {
- $timeStr = "{0:mm\:ss\.fff}" -f $step.Duration
- $status = $step.Status
- $color = switch ($status) {
- "OK" { "Green" }
- "SKIP" { "Yellow" }
- "WARN" { "Yellow" }
- default { "Red" }
- }
- Write-Host (" [{0,-4}] {1,-40} {2}" -f $status, $step.Name, $timeStr) -ForegroundColor $color
- }
-
- if ($script:StepTimerState.TotalStopwatch) {
- $script:StepTimerState.TotalStopwatch.Stop()
- $totalTime = "{0:mm\:ss\.fff}" -f $script:StepTimerState.TotalStopwatch.Elapsed
- Write-Host "----------------------------------------"
- Write-Host " Total: $totalTime" -ForegroundColor Cyan
- }
-}
-
-# ==============================================================================
-# PREREQUISITE FUNCTIONS
-# ==============================================================================
-
-function Test-CommandExists {
- <#
- .SYNOPSIS
- Check if a command exists.
- .PARAMETER Command
- Command name to check.
- .OUTPUTS
- Boolean indicating if command exists.
- #>
- param([Parameter(Mandatory)][string]$Command)
- return [bool](Get-Command $Command -ErrorAction SilentlyContinue)
-}
-
-function Assert-Command {
- <#
- .SYNOPSIS
- Assert that a command exists, exit if not.
- .PARAMETER Command
- Command name to check.
- .PARAMETER ExitCode
- Exit code to use if command is missing (default: 1).
- #>
- param(
- [Parameter(Mandatory)][string]$Command,
- [int]$ExitCode = 1
- )
-
- if (-not (Test-CommandExists $Command)) {
- Write-Error "Required command '$Command' is not available. Aborting."
- exit $ExitCode
- }
-}
-
-function Assert-Commands {
- <#
- .SYNOPSIS
- Assert that multiple commands exist.
- .PARAMETER Commands
- Array of command names to check.
- #>
- param([Parameter(Mandatory)][string[]]$Commands)
-
- foreach ($cmd in $Commands) {
- Assert-Command $cmd
- }
-}
-
-function Test-EnvironmentVariable {
- <#
- .SYNOPSIS
- Check if an environment variable is set.
- .PARAMETER Name
- Environment variable name.
- .OUTPUTS
- Boolean indicating if variable is set and not empty.
- #>
- param([Parameter(Mandatory)][string]$Name)
-
- $value = [Environment]::GetEnvironmentVariable($Name)
- return -not [string]::IsNullOrWhiteSpace($value)
-}
-
-function Assert-EnvironmentVariable {
- <#
- .SYNOPSIS
- Assert that an environment variable is set, exit if not.
- .PARAMETER Name
- Environment variable name.
- .PARAMETER ExitCode
- Exit code to use if variable is missing (default: 1).
- #>
- param(
- [Parameter(Mandatory)][string]$Name,
- [int]$ExitCode = 1
- )
-
- if (-not (Test-EnvironmentVariable $Name)) {
- Write-Error "Required environment variable '$Name' is not set. Aborting."
- exit $ExitCode
- }
-}
-
-# ==============================================================================
-# GIT UTILITIES
-# ==============================================================================
-
-function Get-GitStatus {
- <#
- .SYNOPSIS
- Get git status as structured object.
- .OUTPUTS
- Object with Staged, Modified, Untracked arrays and IsClean boolean.
- #>
- $status = @{
- Staged = @()
- Modified = @()
- Untracked = @()
- Deleted = @()
- IsClean = $true
- }
-
- $statusLines = git status --porcelain 2>$null
- if (-not $statusLines) { return $status }
-
- $status.IsClean = $false
-
- foreach ($line in ($statusLines -split "`n")) {
- if ([string]::IsNullOrWhiteSpace($line)) { continue }
-
- $index = $line.Substring(0, 1)
- $workTree = $line.Substring(1, 1)
- $file = $line.Substring(3)
-
- # Staged changes
- if ($index -match '[MADRC]') {
- $status.Staged += $file
- }
- # Unstaged modifications
- if ($workTree -eq 'M') {
- $status.Modified += $file
- }
- # Deleted files
- if ($index -eq 'D' -or $workTree -eq 'D') {
- $status.Deleted += $file
- }
- # Untracked files
- if ($index -eq '?' -and $workTree -eq '?') {
- $status.Untracked += $file
- }
- }
-
- return $status
-}
-
-function Show-GitStatus {
- <#
- .SYNOPSIS
- Display git status in a formatted, colored output.
- .PARAMETER Status
- Git status object from Get-GitStatus (optional, will fetch if not provided).
- #>
- param([hashtable]$Status)
-
- if (-not $Status) {
- $Status = Get-GitStatus
- }
-
- if ($Status.IsClean) {
- Write-Host " Working directory is clean" -ForegroundColor Green
- return
- }
-
- if ($Status.Staged.Count -gt 0) {
- Write-Host " Staged ($($Status.Staged.Count)):" -ForegroundColor Green
- $Status.Staged | ForEach-Object { Write-Host " + $_" -ForegroundColor Green }
- }
- if ($Status.Modified.Count -gt 0) {
- Write-Host " Modified ($($Status.Modified.Count)):" -ForegroundColor Yellow
- $Status.Modified | ForEach-Object { Write-Host " M $_" -ForegroundColor Yellow }
- }
- if ($Status.Deleted.Count -gt 0) {
- Write-Host " Deleted ($($Status.Deleted.Count)):" -ForegroundColor Red
- $Status.Deleted | ForEach-Object { Write-Host " D $_" -ForegroundColor Red }
- }
- if ($Status.Untracked.Count -gt 0) {
- Write-Host " Untracked ($($Status.Untracked.Count)):" -ForegroundColor Cyan
- $Status.Untracked | ForEach-Object { Write-Host " ? $_" -ForegroundColor Cyan }
- }
-}
-
-function Get-CurrentBranch {
- <#
- .SYNOPSIS
- Get the current git branch name.
- .OUTPUTS
- Branch name string or $null if not in a git repo.
- #>
- try {
- $branch = git rev-parse --abbrev-ref HEAD 2>$null
- if ($LASTEXITCODE -eq 0) { return $branch }
- } catch { }
- return $null
-}
-
-function Get-LastTag {
- <#
- .SYNOPSIS
- Get the most recent git tag.
- .OUTPUTS
- Tag name string or $null if no tags exist.
- #>
- try {
- $tag = git describe --tags --abbrev=0 2>$null
- if ($LASTEXITCODE -eq 0) { return $tag }
- } catch { }
- return $null
-}
-
-function Get-CommitsSinceTag {
- <#
- .SYNOPSIS
- Get commits since a specific tag (or all commits if no tag).
- .PARAMETER Tag
- Tag to start from (optional, uses last tag if not specified).
- .PARAMETER Format
- Output format: oneline, full, hash (default: oneline).
- .OUTPUTS
- Array of commit strings.
- #>
- param(
- [string]$Tag,
- [ValidateSet("oneline", "full", "hash")]
- [string]$Format = "oneline"
- )
-
- if (-not $Tag) {
- $Tag = Get-LastTag
- }
-
- $formatArg = switch ($Format) {
- "oneline" { "--oneline" }
- "full" { "--format=full" }
- "hash" { "--format=%H" }
- }
-
- try {
- if ($Tag) {
- $commits = git log "$Tag..HEAD" $formatArg --no-merges 2>$null
- }
- else {
- $commits = git log -50 $formatArg --no-merges 2>$null
- }
-
- if ($commits) {
- return $commits -split "`n" | Where-Object { $_.Trim() -ne "" }
- }
- } catch { }
-
- return @()
-}
-
-function Get-VersionBumpCommit {
- <#
- .SYNOPSIS
- Find the commit where a version string was introduced in a file.
- .PARAMETER Version
- Version string to search for (e.g., "1.6.1").
- .PARAMETER FilePath
- File path to search in (e.g., "*.csproj").
- .OUTPUTS
- Commit hash where version first appeared, or $null.
- #>
- param(
- [Parameter(Mandatory)][string]$Version,
- [string]$FilePath = "*.csproj"
- )
-
- try {
- # Find commit that introduced this version string
- $commit = git log -S "$Version" --format="%H" --reverse -- $FilePath 2>$null | Select-Object -First 1
- if ($commit) { return $commit.Trim() }
-
- # Try alternative format (without tags)
- $commit = git log -S "$Version" --format="%H" --reverse -- $FilePath 2>$null | Select-Object -First 1
- if ($commit) { return $commit.Trim() }
- } catch { }
-
- return $null
-}
-
-function Get-CommitsForVersion {
- <#
- .SYNOPSIS
- Get commits for a specific version (from previous version to HEAD).
- Designed for pre-commit workflow: version is bumped locally but not yet committed.
- .PARAMETER Version
- Version string (e.g., "1.6.1") - the NEW version being prepared.
- .PARAMETER CsprojPath
- Path to csproj file (absolute or relative).
- .PARAMETER Format
- Output format: oneline, full, hash, detailed (default: oneline).
- "detailed" includes commit message + changed files.
- .OUTPUTS
- Array of commit strings for this version.
- #>
- param(
- [Parameter(Mandatory)][string]$Version,
- [string]$CsprojPath = "*.csproj",
- [ValidateSet("oneline", "full", "hash", "detailed")]
- [string]$Format = "oneline"
- )
-
- # Get git repo root for path conversion
- $gitRoot = (git rev-parse --show-toplevel 2>$null)
- if ($gitRoot) { $gitRoot = $gitRoot.Trim() }
-
- # Get path relative to git root using git itself (handles drive letter issues)
- function ConvertTo-GitPath {
- param([string]$Path)
- if (-not $Path) { return $null }
-
- # If it's a relative path, use it directly
- if (-not [System.IO.Path]::IsPathRooted($Path)) {
- # Get path relative to repo root by combining with current dir offset
- $cwdRelative = git rev-parse --show-prefix 2>$null
- if ($cwdRelative) {
- $cwdRelative = $cwdRelative.Trim().TrimEnd('/')
- if ($cwdRelative) {
- return "$cwdRelative/$($Path -replace '\\', '/')"
- }
- }
- return $Path -replace '\\', '/'
- }
-
- # For absolute paths, try to make relative using git
- Push-Location (Split-Path $Path -Parent) -ErrorAction SilentlyContinue
- try {
- $prefix = git rev-parse --show-prefix 2>$null
- if ($prefix) {
- $prefix = $prefix.Trim().TrimEnd('/')
- $filename = Split-Path $Path -Leaf
- if ($prefix) {
- return "$prefix/$filename"
- }
- return $filename
- }
- }
- finally {
- Pop-Location -ErrorAction SilentlyContinue
- }
-
- # Fallback: normalize to forward slashes
- return $Path -replace '\\', '/'
- }
-
- # Find actual csproj file if glob pattern
- $actualCsprojPath = $CsprojPath
- if ($CsprojPath -match '\*') {
- $found = Get-ChildItem -Path $CsprojPath -Recurse -ErrorAction SilentlyContinue |
- Where-Object { $_.Name -match '\.csproj$' } |
- Select-Object -First 1
- if ($found) { $actualCsprojPath = $found.FullName }
- }
-
- $gitCsprojPath = ConvertTo-GitPath $actualCsprojPath
-
- # Determine commit range
- $range = ""
- try {
- # Check if this version is already committed
- $versionCommit = Get-VersionBumpCommit -Version $Version -FilePath $CsprojPath
-
- if ($versionCommit) {
- # Version already in git history - get commits from that point
- $range = "$versionCommit^..HEAD"
- }
- else {
- # Version NOT committed yet (normal pre-commit workflow)
- # Find the PREVIOUS version from the committed csproj
- if ($gitCsprojPath) {
- $committedContent = git show "HEAD:$gitCsprojPath" 2>$null
- if ($committedContent) {
- $prevVersionMatch = [regex]::Match(($committedContent -join "`n"), '([^<]+)')
- if ($prevVersionMatch.Success) {
- $prevVersion = $prevVersionMatch.Groups[1].Value
- # Find when previous version was introduced
- $prevCommit = Get-VersionBumpCommit -Version $prevVersion -FilePath $CsprojPath
- if ($prevCommit) {
- # Get commits AFTER previous version was set (these are unreleased)
- $range = "$prevCommit..HEAD"
- }
- }
- }
- }
-
- # Fallback to last tag if still no range
- if (-not $range) {
- $lastTag = Get-LastTag
- if ($lastTag) {
- $range = "$lastTag..HEAD"
- }
- }
- }
- } catch { }
-
-
- # For detailed format, get commit + files changed
- if ($Format -eq "detailed") {
- return Get-DetailedCommits -Range $range
- }
-
- $formatArg = switch ($Format) {
- "oneline" { "--oneline" }
- "full" { "--format=full" }
- "hash" { "--format=%H" }
- }
-
- try {
- if ($range) {
- $commits = git log $range $formatArg --no-merges 2>$null
- }
- else {
- $commits = git log -30 $formatArg --no-merges 2>$null
- }
-
- if ($commits) {
- return $commits -split "`n" | Where-Object { $_.Trim() -ne "" }
- }
- } catch { }
-
- return @()
-}
-
-function Get-DetailedCommits {
- <#
- .SYNOPSIS
- Get detailed commit info including changed files.
- .PARAMETER Range
- Git commit range (e.g., "v1.0.0..HEAD").
- .PARAMETER MaxCommits
- Maximum commits to return (default: 50).
- .OUTPUTS
- Array of formatted strings: "hash message [files: file1, file2, ...]"
- #>
- param(
- [string]$Range,
- [int]$MaxCommits = 50
- )
-
- $results = @()
-
- try {
- # Get commit hashes
- if ($Range) {
- $hashes = git log $Range --format="%H" --no-merges -n $MaxCommits 2>$null
- }
- else {
- $hashes = git log --format="%H" --no-merges -n $MaxCommits 2>$null
- }
-
- if (-not $hashes) { return @() }
-
- $hashArray = $hashes -split "`n" | Where-Object { $_.Trim() -ne "" }
-
- foreach ($hash in $hashArray) {
- $hash = $hash.Trim()
- if (-not $hash) { continue }
-
- # Get commit message (first line)
- $message = git log -1 --format="%s" $hash 2>$null
- if (-not $message) { continue }
-
- # Get changed files (source files only)
- $files = git diff-tree --no-commit-id --name-only -r $hash 2>$null
- $sourceFiles = @()
- if ($files) {
- $sourceFiles = ($files -split "`n" | Where-Object {
- $_.Trim() -ne "" -and ($_ -match '\.(cs|fs|vb|ts|js|py|java|go|rs|cpp|c|h)$')
- }) | Select-Object -First 5 # Limit to 5 files per commit
- }
-
- # Format output
- $shortHash = $hash.Substring(0, 7)
- if ($sourceFiles.Count -gt 0) {
- $fileList = $sourceFiles -join ", "
- $results += "$shortHash $message [files: $fileList]"
- }
- else {
- $results += "$shortHash $message"
- }
- }
- } catch { }
-
- return $results
-}
-
-function Get-UncommittedChanges {
- <#
- .SYNOPSIS
- Get summary of uncommitted changes (staged, unstaged, untracked) with meaningful descriptions.
- .PARAMETER IncludeContent
- If true, includes file content diffs (for AI analysis).
- .PARAMETER FileFilter
- File extension filter (default: .cs for C# files).
- .OUTPUTS
- Object with Staged, Modified, Untracked arrays and Summary with change descriptions.
- #>
- param(
- [switch]$IncludeContent,
- [string]$FileFilter = ".cs"
- )
-
- $result = @{
- Staged = @()
- Modified = @()
- Untracked = @()
- Deleted = @()
- Summary = @()
- }
-
- try {
- # Get current directory prefix relative to repo root
- $cwdPrefix = git rev-parse --show-prefix 2>$null
- if ($cwdPrefix) { $cwdPrefix = $cwdPrefix.Trim().TrimEnd('/') }
-
- # Get all changes using git status porcelain
- $status = git status --porcelain 2>$null
- if (-not $status) { return $result }
-
- $statusLines = $status -split "`n" | Where-Object { $_.Trim() -ne "" }
-
- foreach ($line in $statusLines) {
- if ($line.Length -lt 3) { continue }
- $statusCode = $line.Substring(0, 2)
- $filePath = $line.Substring(3).Trim().Trim('"')
-
- # Filter by extension
- if ($FileFilter -and -not $filePath.EndsWith($FileFilter)) { continue }
-
- # Convert repo-relative path to cwd-relative path for git diff
- $diffPath = $filePath
- if ($cwdPrefix -and $filePath.StartsWith("$cwdPrefix/")) {
- $diffPath = $filePath.Substring($cwdPrefix.Length + 1)
- }
-
- # Categorize by status (store both paths)
- $pathInfo = @{ Full = $filePath; Diff = $diffPath }
- if ($statusCode -match '^\?\?') {
- $result.Untracked += $pathInfo
- } elseif ($statusCode -match '^D' -or $statusCode -match '^.D') {
- # Deleted files (staged or unstaged)
- $result.Deleted += $pathInfo
- } elseif ($statusCode -match '^[MARC]') {
- $result.Staged += $pathInfo
- } elseif ($statusCode -match '^.[MARC]') {
- $result.Modified += $pathInfo
- }
- }
-
- # Process modified/staged files (get diff)
- $allModified = @($result.Staged) + @($result.Modified)
- foreach ($fileInfo in $allModified) {
- if (-not $fileInfo) { continue }
-
- $diffPath = $fileInfo.Diff
- $fullPath = $fileInfo.Full
-
- $diff = git diff -- $diffPath 2>$null
- if (-not $diff) { $diff = git diff --cached -- $diffPath 2>$null }
-
- $fileName = Split-Path $fullPath -Leaf
- $className = $fileName -replace '\.cs$', ''
-
- if ($diff) {
- $changes = @()
- $diffLines = $diff -split "`n"
-
- foreach ($line in $diffLines) {
- # Added method/class/property
- if ($line -match '^\+\s*(public|private|protected|internal)\s+static\s+\w+\s+(\w+)\s*\(') {
- $changes += "Added static method $($Matches[2])"
- }
- elseif ($line -match '^\+\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
- $methodName = $Matches[2]
- if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
- $changes += "Added method $methodName"
- }
- }
- elseif ($line -match '^\+\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
- $changes += "Added $($Matches[2]) $($Matches[3])"
- }
- # Detect try/catch blocks, error handling
- elseif ($line -match '^\+.*catch\s*\(') {
- $changes += "Added exception handling"
- }
- }
-
- $changes = $changes | Select-Object -Unique | Select-Object -First 4
-
- if ($changes.Count -gt 0) {
- $result.Summary += "(uncommitted) $className`: $($changes -join ', ')"
- }
- else {
- # Fallback to line count
- $addCount = ($diffLines | Where-Object { $_ -match '^\+[^+]' }).Count
- $delCount = ($diffLines | Where-Object { $_ -match '^-[^-]' }).Count
- $result.Summary += "(uncommitted) $className`: Modified (+$addCount/-$delCount lines)"
- }
- }
- else {
- $result.Summary += "(uncommitted) $className`: Modified"
- }
- }
-
- # Process untracked files (new files)
- foreach ($fileInfo in $result.Untracked) {
- if (-not $fileInfo) { continue }
-
- $diffPath = $fileInfo.Diff
- $fullPath = $fileInfo.Full
-
- $fileName = Split-Path $fullPath -Leaf
- $className = $fileName -replace '\.cs$', ''
-
- # Read file to understand what it contains
- $content = Get-Content $diffPath -Raw -ErrorAction SilentlyContinue
- if ($content) {
- $features = @()
-
- if ($content -match 'class\s+(\w+)') { $features += "class $($Matches[1])" }
- if ($content -match 'interface\s+(\w+)') { $features += "interface $($Matches[1])" }
- if ($content -match 'enum\s+(\w+)') { $features += "enum $($Matches[1])" }
- if ($content -match '\[Fact\]|\[Theory\]') { $features += "unit tests" }
-
- if ($features.Count -gt 0) {
- $result.Summary += "(new file) $className`: Added $($features -join ', ')"
- }
- else {
- $result.Summary += "(new file) $className`: New file"
- }
- }
- else {
- $result.Summary += "(new file) $className`: New file"
- }
- }
-
- # Process deleted files
- foreach ($fileInfo in $result.Deleted) {
- if (-not $fileInfo) { continue }
-
- $fullPath = $fileInfo.Full
- $fileName = Split-Path $fullPath -Leaf
- $className = $fileName -replace '\.cs$', ''
-
- $result.Summary += "(deleted) $className`: Removed"
- }
-
- if ($IncludeContent) {
- $result.DiffContent = git diff --cached 2>$null
- if (-not $result.DiffContent) { $result.DiffContent = git diff 2>$null }
- }
- } catch { }
-
- return $result
-}
-
-function Get-CommitChangesAnalysis {
- <#
- .SYNOPSIS
- Analyze commits in a range and extract meaningful changes from diffs.
- .PARAMETER Version
- Version string to find commits for.
- .PARAMETER CsprojPath
- Path to csproj file.
- .PARAMETER FileFilter
- File extension filter (default: .cs).
- .OUTPUTS
- Array of change summary strings (like Get-UncommittedChanges).
- #>
- param(
- [Parameter(Mandatory)][string]$Version,
- [string]$CsprojPath = "*.csproj",
- [string]$FileFilter = ".cs"
- )
-
- $summaries = @()
-
- try {
- # Get commit range for this version
- $range = ""
-
- # Find csproj
- $actualCsprojPath = $CsprojPath
- if ($CsprojPath -match '\*') {
- $found = Get-ChildItem -Path $CsprojPath -Recurse -ErrorAction SilentlyContinue |
- Where-Object { $_.Name -match '\.csproj$' } |
- Select-Object -First 1
- if ($found) { $actualCsprojPath = $found.FullName }
- }
-
- # Get git path
- $cwdPrefix = git rev-parse --show-prefix 2>$null
- if ($cwdPrefix) { $cwdPrefix = $cwdPrefix.Trim().TrimEnd('/') }
-
- $gitCsprojPath = $actualCsprojPath -replace '\\', '/'
- if ([System.IO.Path]::IsPathRooted($actualCsprojPath)) {
- Push-Location (Split-Path $actualCsprojPath -Parent) -ErrorAction SilentlyContinue
- try {
- $prefix = git rev-parse --show-prefix 2>$null
- if ($prefix) {
- $prefix = $prefix.Trim().TrimEnd('/')
- $filename = Split-Path $actualCsprojPath -Leaf
- $gitCsprojPath = if ($prefix) { "$prefix/$filename" } else { $filename }
- }
- } finally { Pop-Location -ErrorAction SilentlyContinue }
- }
-
- # Determine commit range
- $versionCommit = Get-VersionBumpCommit -Version $Version -FilePath $CsprojPath
-
- if ($versionCommit) {
- $range = "$versionCommit^..HEAD"
- }
- else {
- # Version not committed - find previous version
- $committedContent = git show "HEAD:$gitCsprojPath" 2>$null
- if ($committedContent) {
- $prevVersionMatch = [regex]::Match(($committedContent -join "`n"), '([^<]+)')
- if ($prevVersionMatch.Success) {
- $prevVersion = $prevVersionMatch.Groups[1].Value
- $prevCommit = Get-VersionBumpCommit -Version $prevVersion -FilePath $CsprojPath
- if ($prevCommit) {
- $range = "$prevCommit..HEAD"
- }
- }
- }
-
- if (-not $range) {
- $lastTag = Get-LastTag
- if ($lastTag) { $range = "$lastTag..HEAD" }
- }
- }
-
- if (-not $range) { return @() }
-
- # Get commits
- $hashes = git log $range --format="%H" --no-merges -n 30 2>$null
- if (-not $hashes) { return @() }
-
- $hashArray = $hashes -split "`n" | Where-Object { $_.Trim() -ne "" }
-
- foreach ($hash in $hashArray) {
- $hash = $hash.Trim()
- if (-not $hash) { continue }
-
- $message = git log -1 --format="%s" $hash 2>$null
- $shortHash = $hash.Substring(0, 7)
-
- # Get files changed in this commit
- $files = git diff-tree --no-commit-id --name-only -r $hash 2>$null
- if (-not $files) { continue }
-
- $sourceFiles = $files -split "`n" | Where-Object {
- $_.Trim() -ne "" -and $_.EndsWith($FileFilter)
- }
-
- foreach ($file in $sourceFiles) {
- $file = $file.Trim()
- if (-not $file) { continue }
-
- $fileName = Split-Path $file -Leaf
- $className = $fileName -replace '\.cs$', ''
-
- # Get diff for this file in this commit
- $diff = git show $hash --format="" -- $file 2>$null
-
- if ($diff) {
- $changes = @()
- $diffLines = $diff -split "`n"
-
- foreach ($line in $diffLines) {
- # Added method/class/property
- if ($line -match '^\+\s*(public|private|protected|internal)\s+static\s+\w+\s+(\w+)\s*\(') {
- $changes += "Added static method $($Matches[2])"
- }
- elseif ($line -match '^\+\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
- $methodName = $Matches[2]
- if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
- $changes += "Added method $methodName"
- }
- }
- elseif ($line -match '^\+\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
- $changes += "Added $($Matches[2]) $($Matches[3])"
- }
- # Removed
- elseif ($line -match '^-\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
- $changes += "Removed $($Matches[2]) $($Matches[3])"
- }
- elseif ($line -match '^-\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
- $methodName = $Matches[2]
- if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
- $changes += "Removed method $methodName"
- }
- }
- # Exception handling
- elseif ($line -match '^\+.*catch\s*\(') {
- $changes += "Added exception handling"
- }
- }
-
- $changes = $changes | Select-Object -Unique | Select-Object -First 4
-
- if ($changes.Count -gt 0) {
- $summaries += "(commit $shortHash) $className`: $($changes -join ', ')"
- }
- }
- }
-
- # Also add commit message context if no detailed changes found for any file
- if (-not ($summaries | Where-Object { $_ -match $shortHash })) {
- $summaries += "(commit $shortHash) $message"
- }
- }
- } catch { }
-
- return $summaries | Select-Object -Unique
-}
-
-# ==============================================================================
-# CONSOLE OUTPUT HELPERS
-# ==============================================================================
-
-function Write-Banner {
- <#
- .SYNOPSIS
- Write a banner/header to console.
- .PARAMETER Title
- Banner title text.
- .PARAMETER Width
- Banner width (default: 50).
- .PARAMETER Color
- Text color (default: Cyan).
- #>
- param(
- [Parameter(Mandatory)][string]$Title,
- [int]$Width = 50,
- [string]$Color = "Cyan"
- )
-
- $border = "=" * $Width
- Write-Host ""
- Write-Host $border -ForegroundColor $Color
- Write-Host $Title -ForegroundColor $Color
- Write-Host $border -ForegroundColor $Color
- Write-Host ""
-}
-
-function Write-Success {
- <#
- .SYNOPSIS
- Write a success message.
- #>
- param([Parameter(Mandatory)][string]$Message)
- Write-Host $Message -ForegroundColor Green
-}
-
-function Write-Warning {
- <#
- .SYNOPSIS
- Write a warning message.
- #>
- param([Parameter(Mandatory)][string]$Message)
- Write-Host "WARNING: $Message" -ForegroundColor Yellow
-}
-
-function Write-Failure {
- <#
- .SYNOPSIS
- Write a failure/error message.
- #>
- param([Parameter(Mandatory)][string]$Message)
- Write-Host "ERROR: $Message" -ForegroundColor Red
-}
-
-function Write-Info {
- <#
- .SYNOPSIS
- Write an info message.
- #>
- param([Parameter(Mandatory)][string]$Message)
- Write-Host $Message -ForegroundColor Gray
-}
-
-# ==============================================================================
-# MODULE EXPORTS
-# ==============================================================================
-
-Export-ModuleMember -Function @(
- # Step Timing
- 'Initialize-StepTimer'
- 'Start-Step'
- 'Complete-Step'
- 'Get-StepTimings'
- 'Show-TimingSummary'
-
- # Prerequisites
- 'Test-CommandExists'
- 'Assert-Command'
- 'Assert-Commands'
- 'Test-EnvironmentVariable'
- 'Assert-EnvironmentVariable'
-
- # Git Utilities
- 'Get-GitStatus'
- 'Show-GitStatus'
- 'Get-CurrentBranch'
- 'Get-LastTag'
- 'Get-CommitsSinceTag'
- 'Get-VersionBumpCommit'
- 'Get-CommitsForVersion'
- 'Get-DetailedCommits'
- 'Get-UncommittedChanges'
- 'Get-CommitChangesAnalysis'
-
- # Console Output
- 'Write-Banner'
- 'Write-Success'
- 'Write-Warning'
- 'Write-Failure'
- 'Write-Info'
-)
diff --git a/src/scripts/Force-AmendTaggedCommit.bat b/src/scripts/Force-AmendTaggedCommit.bat
deleted file mode 100644
index 616d358..0000000
--- a/src/scripts/Force-AmendTaggedCommit.bat
+++ /dev/null
@@ -1,6 +0,0 @@
-@echo off
-setlocal
-
-powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
-
-pause
\ No newline at end of file
diff --git a/src/scripts/Force-AmendTaggedCommit.ps1 b/src/scripts/Force-AmendTaggedCommit.ps1
deleted file mode 100644
index 95b6d44..0000000
--- a/src/scripts/Force-AmendTaggedCommit.ps1
+++ /dev/null
@@ -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
-}
diff --git a/src/scripts/Release-NuGetPackage.bat b/src/scripts/Release-NuGetPackage.bat
deleted file mode 100644
index ba9cefe..0000000
--- a/src/scripts/Release-NuGetPackage.bat
+++ /dev/null
@@ -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"
diff --git a/src/scripts/Release-NuGetPackage.ps1 b/src/scripts/Release-NuGetPackage.ps1
deleted file mode 100644
index b06387c..0000000
--- a/src/scripts/Release-NuGetPackage.ps1
+++ /dev/null
@@ -1,1118 +0,0 @@
-<#
-.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
- - If -DryRun: Show summary and exit (no changes made)
- - 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)
-
-.PARAMETER DryRun
- If specified, runs build and tests without publishing.
- - Bypasses branch check (warns instead)
- - No changes are made to NuGet, GitHub, or git tags
-
-.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.
-
- Dry run (test without publishing):
- .\Release-NuGetPackage.ps1 -DryRun
-
- Re-run release (idempotent - skips NuGet/GitHub if already released):
- .\Release-NuGetPackage.ps1
-
- Generate changelog and update LICENSE year:
- .\Generate-Changelog.ps1
- .\Generate-Changelog.ps1 -DryRun
-
-.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
-#>
-
-param(
- [switch]$DryRun
-)
-
-# ==============================================================================
-# PATH CONFIGURATION
-# ==============================================================================
-
-$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
-$solutionDir = Split-Path -Parent $scriptDir
-$repoRoot = Split-Path -Parent $solutionDir
-$projectDir = "$solutionDir\MaksIT.Core"
-$outputDir = "$projectDir\bin\Release"
-$testProjectDir = "$solutionDir\MaksIT.Core.Tests"
-$csprojPath = "$projectDir\MaksIT.Core.csproj"
-$testResultsDir = "$repoRoot\TestResults"
-
-# ==============================================================================
-# IMPORT MODULES
-# ==============================================================================
-
-# Import build utilities module
-$buildUtilsPath = Join-Path $scriptDir "BuildUtils.psm1"
-if (Test-Path $buildUtilsPath) {
- Import-Module $buildUtilsPath -Force
-}
-else {
- Write-Error "BuildUtils.psm1 not found at $buildUtilsPath"
- exit 1
-}
-
-# Initialize step timer
-Initialize-StepTimer
-
-# ==============================================================================
-# CONFIGURATION
-# ==============================================================================
-
-if ($TestChangelog) {
- Write-Banner "TEST CHANGELOG MODE - AI generation only"
-}
-elseif ($DryRun) {
- Write-Banner "DRY RUN MODE - No changes will be made"
-}
-
-# NuGet source
-$nugetSource = "https://api.nuget.org/v3/index.json"
-
-# ==============================================================================
-# LOAD SETTINGS FROM JSON
-# ==============================================================================
-
-$settingsPath = Join-Path $scriptDir "scriptsettings.json"
-if (Test-Path $settingsPath) {
- $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
- Write-Host "Loaded settings from scriptsettings.json"
-}
-else {
- Write-Error "Settings file not found: $settingsPath"
- exit 1
-}
-
-# Resolve paths from settings (relative to script location)
-$changelogPath = if ($settings.paths.changelogPath) {
- [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
-}
-else {
- "$repoRoot\CHANGELOG.md"
-}
-
-# Release branch setting
-$releaseBranch = if ($settings.release.branch) { $settings.release.branch } else { "main" }
-
-# ==============================================================================
-# SECRETS FROM ENVIRONMENT VARIABLES
-# ==============================================================================
-
-# Get env var names from settings (allows customization)
-$envVars = $settings.environmentVariables
-
-# NuGet API key
-$nugetApiKey = [Environment]::GetEnvironmentVariable($envVars.nugetApiKey)
-if (-not $nugetApiKey) {
- Write-Error "Error: API key not found in environment variable $($envVars.nugetApiKey)."
- exit 1
-}
-
-# GitHub token (set for gh CLI)
-$env:GH_TOKEN = [Environment]::GetEnvironmentVariable($envVars.githubToken)
-
-# Package signing password (optional)
-$packageSigningCertPassword = [Environment]::GetEnvironmentVariable($envVars.signingCertPassword)
-
-# SMTP password (optional)
-$smtpPassword = [Environment]::GetEnvironmentVariable($envVars.smtpPassword)
-
-# ==============================================================================
-# NON-SECRET SETTINGS
-# ==============================================================================
-
-# Quality gates
-$coverageThreshold = $settings.qualityGates.coverageThreshold
-$failOnVulnerabilities = $settings.qualityGates.failOnVulnerabilities
-
-# Package signing (non-secret parts)
-$packageSigningEnabled = $settings.packageSigning.enabled
-$packageSigningCertPath = $settings.packageSigning.certificatePath
-$packageSigningTimestamper = $settings.packageSigning.timestampServer
-
-# Email notification (non-secret parts)
-$emailEnabled = $settings.emailNotification.enabled
-$emailSmtpServer = $settings.emailNotification.smtpServer
-$emailSmtpPort = $settings.emailNotification.smtpPort
-$emailUseSsl = $settings.emailNotification.useSsl
-$emailFrom = $settings.emailNotification.from
-$emailTo = $settings.emailNotification.to
-
-# ==============================================================================
-# PREREQUISITE CHECKS
-# ==============================================================================
-
-Assert-Commands @("dotnet", "git", "gh", "docker")
-
-# ==============================================================================
-# GIT STATUS VALIDATION
-# ==============================================================================
-
-# Check for uncommitted changes (always block)
-Write-Host "Checking for uncommitted changes..."
-$gitStatus = Get-GitStatus
-
-if (-not $gitStatus.IsClean) {
- $fileCount = $gitStatus.Staged.Count + $gitStatus.Modified.Count + $gitStatus.Untracked.Count + $gitStatus.Deleted.Count
- Write-Host "ERROR: You have $fileCount uncommitted file(s). Commit or stash them first." -ForegroundColor Red
- Show-GitStatus $gitStatus
- exit 1
-}
-
-Write-Host "Working directory is clean."
-
-# ==============================================================================
-# VERSION & TAG DISCOVERY
-# ==============================================================================
-
-# Read latest version from CHANGELOG.md
-Write-Host "Reading version from CHANGELOG.md..."
-if (-not (Test-Path $changelogPath)) {
- Write-Error "CHANGELOG.md not found at $changelogPath"
- exit 1
-}
-
-$changelogContent = Get-Content $changelogPath -Raw
-$versionMatch = [regex]::Match($changelogContent, '##\s+v(\d+\.\d+\.\d+)')
-
-if (-not $versionMatch.Success) {
- Write-Error "No version found in CHANGELOG.md (expected format: ## v1.2.3)"
- exit 1
-}
-
-$version = $versionMatch.Groups[1].Value
-$tag = "v$version"
-Write-Host "Latest changelog version: $version"
-
-# Find commit with this tag
-$tagCommit = git rev-parse "$tag^{commit}" 2>$null
-if ($LASTEXITCODE -ne 0 -or -not $tagCommit) {
- Write-Host ""
- Write-Host "ERROR: Tag $tag not found." -ForegroundColor Red
- Write-Host "The release process requires a tag matching the changelog version." -ForegroundColor Yellow
- Write-Host ""
- Write-Host "To fix, run:" -ForegroundColor Cyan
- Write-Host " git tag $tag " -ForegroundColor Cyan
- Write-Host " git push origin $tag" -ForegroundColor Cyan
- exit 1
-}
-
-$shortCommit = $tagCommit.Substring(0, 7)
-Write-Host "Found tag $tag -> commit $shortCommit"
-
-# Validate tag commit is on release branch
-if ($releaseBranch) {
- $branchContains = git branch --contains $tagCommit --list $releaseBranch 2>$null
- if (-not $branchContains) {
- Write-Host ""
- Write-Host "ERROR: Tag $tag (commit $shortCommit) is not on branch '$releaseBranch'." -ForegroundColor Red
- Write-Host "Release is only allowed from the configured branch." -ForegroundColor Yellow
- Write-Host ""
- Write-Host "Either:" -ForegroundColor Cyan
- Write-Host " 1. Merge the tagged commit to '$releaseBranch'" -ForegroundColor Cyan
- Write-Host " 2. Change release.branch in scriptsettings.json" -ForegroundColor Cyan
- exit 1
- }
- Write-Host "Tag is on branch '$releaseBranch'" -ForegroundColor Green
-}
-
-# Extract target framework from csproj (needed for Docker image)
-[xml]$csproj = Get-Content $csprojPath
-$targetFramework = ($csproj.Project.PropertyGroup |
- Where-Object { $_.TargetFramework } |
- Select-Object -First 1).TargetFramework
-
-if (-not $targetFramework) {
- # Try TargetFrameworks (plural) for multi-target projects, take first one
- $targetFrameworks = ($csproj.Project.PropertyGroup |
- Where-Object { $_.TargetFrameworks } |
- Select-Object -First 1).TargetFrameworks
- if ($targetFrameworks) {
- $targetFramework = ($targetFrameworks -split ';')[0]
- }
-}
-
-if (-not $targetFramework) {
- Write-Error "TargetFramework not found in $csprojPath"
- exit 1
-}
-
-# Convert "net8.0" to "8.0" for Docker image tag
-$dotnetVersion = $targetFramework -replace '^net', ''
-Write-Host "Target framework: $targetFramework (Docker SDK: $dotnetVersion)"
-
-# ==============================================================================
-# CHANGELOG VALIDATION
-# ==============================================================================
-
-$tag = "v$version"
-$releaseName = "Release $version"
-
-Start-Step "Validating CHANGELOG.md"
-
-if (-not (Test-Path $changelogPath)) {
- Complete-Step "FAIL"
- Write-Error "CHANGELOG.md not found. Run .\Generate-Changelog.ps1 first."
- exit 1
-}
-
-$changelog = Get-Content $changelogPath -Raw
-$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+|\Z)"
-$match = [regex]::Match($changelog, $pattern)
-
-if (-not $match.Success) {
- Complete-Step "FAIL"
- Write-Host ""
- Write-Host "No CHANGELOG entry for v$version" -ForegroundColor Red
- Write-Host "Run: .\Generate-Changelog.ps1" -ForegroundColor Yellow
- exit 1
-}
-
-$releaseNotes = $match.Value.Trim()
-Complete-Step "OK"
-Write-Host ""
-Write-Host "Release notes (v$version):" -ForegroundColor Gray
-Write-Host $releaseNotes
-
-# ==============================================================================
-# NUGET VERSION CHECK
-# ==============================================================================
-
-Start-Step "Checking NuGet.org release status"
-$packageName = "MaksIT.Core"
-$nugetCheckUrl = "https://api.nuget.org/v3-flatcontainer/$($packageName.ToLower())/index.json"
-$script:nugetAlreadyReleased = $false
-
-try {
- $existingVersions = (Invoke-RestMethod -Uri $nugetCheckUrl -ErrorAction Stop).versions
- if ($existingVersions -contains $version) {
- $script:nugetAlreadyReleased = $true
- Write-Host " Version $version already on NuGet.org - will skip NuGet publish" -ForegroundColor Yellow
- Complete-Step "SKIP"
- }
- else {
- Write-Host " Version $version not yet on NuGet.org - will release" -ForegroundColor Green
- Complete-Step "OK"
- }
-}
-catch {
- Write-Host " Could not check NuGet (will attempt publish): $_" -ForegroundColor Yellow
- Complete-Step "SKIP"
-}
-
-# ==============================================================================
-# GITHUB CONFIGURATION
-# ==============================================================================
-
-# Read GitHub settings from config
-$gitHubConfig = $settings.gitHub
-$gitHubEnabled = if ($null -ne $gitHubConfig.enabled) { $gitHubConfig.enabled } else { $true }
-$gitHubRepo = $null
-
-if ($gitHubEnabled) {
- # Get remote URL to check if it's GitHub or another host
- $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
- }
-
- # Check if remote is GitHub (supports github.com in HTTPS or SSH format)
- $isGitHubRemote = $remoteUrl -match "github\.com[:/]"
-
- if ($isGitHubRemote) {
- # Auto-detect owner/repo from GitHub remote URL
- if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") {
- $owner = $matches['owner']
- $repoName = $matches['repo']
- $gitHubRepo = "$owner/$repoName"
- Write-Host "GitHub repository (auto-detected): $gitHubRepo"
- }
- else {
- Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
- exit 1
- }
- }
- else {
- # Remote is not GitHub (e.g., Gitea, GitLab, etc.) - use fallback from config
- if ($gitHubConfig.repository -and $gitHubConfig.repository.Trim() -ne "") {
- $gitHubRepo = $gitHubConfig.repository.Trim()
- Write-Host "GitHub repository (from config, remote is not GitHub): $gitHubRepo"
- }
- else {
- Write-Error "Remote origin is not GitHub ($remoteUrl) and no fallback repository configured in scriptsettings.json (gitHub.repository)."
- exit 1
- }
- }
-
- # Ensure GH_TOKEN is set
- if (-not $env:GH_TOKEN) {
- Write-Error "GitHub token not found. Set environment variable: $($envVars.githubToken)"
- exit 1
- }
-
- # Test GitHub CLI authentication
- Write-Host "Authenticating GitHub CLI using GH_TOKEN..."
- $authTest = gh api user 2>$null
-
- if ($LASTEXITCODE -ne 0 -or -not $authTest) {
- Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
- exit 1
- }
-
- Write-Host "GitHub CLI authenticated successfully via GH_TOKEN."
-
- # Check if GitHub release already exists
- Start-Step "Checking GitHub release status"
- $script:githubAlreadyReleased = $false
- $existingGitHubRelease = gh release view $tag --repo $gitHubRepo 2>$null
- if ($LASTEXITCODE -eq 0 -and $existingGitHubRelease) {
- $script:githubAlreadyReleased = $true
- Write-Host " GitHub release $tag already exists - will skip" -ForegroundColor Yellow
- Complete-Step "SKIP"
- }
- else {
- Write-Host " GitHub release $tag not found - will create" -ForegroundColor Green
- Complete-Step "OK"
- }
-}
-else {
- Write-Host "GitHub releases: DISABLED (skipping GitHub authentication)" -ForegroundColor Yellow
- $script:githubAlreadyReleased = $false
-}
-
-# ==============================================================================
-# BUILD & TEST PHASE
-# ==============================================================================
-
-Start-Step "Cleaning previous builds"
-# Use direct folder deletion instead of dotnet clean (avoids package resolution issues)
-$foldersToClean = @(
- "$projectDir\bin",
- "$projectDir\obj",
- "$testProjectDir\bin",
- "$testProjectDir\obj"
-)
-foreach ($folder in $foldersToClean) {
- if (Test-Path $folder) {
- Remove-Item -Path $folder -Recurse -Force -ErrorAction SilentlyContinue
- Write-Host " Removed: $folder"
- }
-}
-
-Complete-Step "OK"
-
-Start-Step "Restoring NuGet packages"
-dotnet restore $solutionDir\MaksIT.Core.sln --nologo -v q
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "NuGet restore failed. Check your internet connection or run 'dotnet nuget locals all --clear' and try again."
- exit 1
-}
-
-Complete-Step "OK"
-
-# ==============================================================================
-# SECURITY SCAN
-# ==============================================================================
-
-Start-Step "Scanning for vulnerable packages"
-$vulnerabilityOutput = dotnet list $solutionDir\MaksIT.Core.sln package --vulnerable 2>&1 | Out-String
-
-# Check if vulnerabilities were found
-$hasVulnerabilities = $vulnerabilityOutput -match "has the following vulnerable packages"
-
-if ($hasVulnerabilities) {
- Write-Host $vulnerabilityOutput -ForegroundColor Yellow
- if ($failOnVulnerabilities -and -not $DryRun) {
- Complete-Step "FAIL"
- Write-Error "Vulnerable packages detected. Update packages or set `$failOnVulnerabilities = `$false to bypass."
- exit 1
- }
- else {
- Write-Host " WARNING: Vulnerable packages found (bypassed)" -ForegroundColor Yellow
- Complete-Step "WARN"
- }
-}
-else {
- Write-Host " No known vulnerabilities found" -ForegroundColor Green
- Complete-Step "OK"
-}
-
-# ==============================================================================
-# WINDOWS BUILD & TEST
-# ==============================================================================
-
-Start-Step "Building main project (Windows)"
-dotnet build $projectDir -c Release --nologo -v q --no-restore
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Main project build failed."
- exit 1
-}
-
-Complete-Step "OK"
-
-Start-Step "Building test project (Windows)"
-dotnet build $testProjectDir -c Release --nologo -v q --no-restore
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Test project build failed."
- exit 1
-}
-
-Complete-Step "OK"
-
-Start-Step "Running Windows tests with coverage"
-
-# Create test results directory
-if (-not (Test-Path $testResultsDir)) {
- New-Item -ItemType Directory -Path $testResultsDir -Force | Out-Null
-}
-
-# Run tests with TRX logger and coverage collection
-$windowsTestResultFile = "$testResultsDir\Windows-TestResults.trx"
-$testOutput = dotnet test $testProjectDir -c Release --nologo -v q --no-build `
- --logger "trx;LogFileName=$windowsTestResultFile" `
- --collect:"XPlat Code Coverage" `
- --results-directory "$testResultsDir" 2>&1 | Out-String
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Host $testOutput
- Write-Error "Windows tests failed. Aborting release process."
- exit 1
-}
-
-# Parse test results
-if ($testOutput -match "Passed:\s*(\d+)") { $script:windowsTestsPassed = [int]$Matches[1] } else { $script:windowsTestsPassed = 0 }
-if ($testOutput -match "Failed:\s*(\d+)") { $script:windowsTestsFailed = [int]$Matches[1] } else { $script:windowsTestsFailed = 0 }
-if ($testOutput -match "Skipped:\s*(\d+)") { $script:windowsTestsSkipped = [int]$Matches[1] } else { $script:windowsTestsSkipped = 0 }
-$script:windowsTestsTotal = $script:windowsTestsPassed + $script:windowsTestsFailed + $script:windowsTestsSkipped
-
-Write-Host " Tests: $script:windowsTestsPassed passed, $script:windowsTestsFailed failed, $script:windowsTestsSkipped skipped" -ForegroundColor Green
-Write-Host " Results: $windowsTestResultFile" -ForegroundColor Gray
-Complete-Step "OK"
-
-# ==============================================================================
-# CODE COVERAGE CHECK
-# ==============================================================================
-
-Start-Step "Analyzing code coverage"
-
-# Find the coverage file (Coverlet creates it in a GUID folder)
-$coverageFile = Get-ChildItem -Path $testResultsDir -Filter "coverage.cobertura.xml" -Recurse |
- Sort-Object LastWriteTime -Descending |
- Select-Object -First 1
-
-if ($coverageFile) {
- # Parse coverage from Cobertura XML
- [xml]$coverageXml = Get-Content $coverageFile.FullName
- $lineRate = [double]$coverageXml.coverage.'line-rate' * 100
- $branchRate = [double]$coverageXml.coverage.'branch-rate' * 100
- $script:codeCoverage = [math]::Round($lineRate, 2)
- $script:branchCoverage = [math]::Round($branchRate, 2)
-
- Write-Host " Line coverage: $script:codeCoverage%" -ForegroundColor $(if ($script:codeCoverage -ge $coverageThreshold) { "Green" } else { "Yellow" })
- Write-Host " Branch coverage: $script:branchCoverage%" -ForegroundColor Gray
- Write-Host " Report: $($coverageFile.FullName)" -ForegroundColor Gray
-
- # Check threshold
- if ($coverageThreshold -gt 0 -and $script:codeCoverage -lt $coverageThreshold) {
- Complete-Step "FAIL"
- Write-Error "Code coverage ($script:codeCoverage%) is below threshold ($coverageThreshold%)."
- exit 1
- }
- Complete-Step "OK"
-}
-else {
- Write-Host " Coverage file not found (coverlet may not be installed)" -ForegroundColor Yellow
- $script:codeCoverage = 0
- $script:branchCoverage = 0
- Complete-Step "SKIP"
-}
-
-# ==============================================================================
-# LINUX BUILD & TEST (Docker)
-# ==============================================================================
-
-Start-Step "Checking Docker availability"
-docker info 2>&1 | Out-Null
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Docker is not running. Start Docker Desktop and try again."
- exit 1
-}
-
-# Extract Docker version info
-$dockerVersion = docker version --format '{{.Server.Version}}' 2>$null
-$dockerOS = docker version --format '{{.Server.Os}}' 2>$null
-Write-Host " Docker: $dockerVersion ($dockerOS)"
-Complete-Step "OK"
-
-# Convert Windows path to Docker-compatible path
-$dockerRepoPath = $repoRoot -replace '\\', '/' -replace '^([A-Za-z]):', '/$1'
-
-# Build Docker image name from target framework
-$dockerImage = "mcr.microsoft.com/dotnet/sdk:$dotnetVersion"
-
-Start-Step "Building & testing in Linux ($dockerImage)"
-# Build main project, then test project, then run tests with TRX logger - all in one container run
-$linuxTestResultFile = "TestResults/Linux-TestResults.trx"
-$dockerTestOutput = docker run --rm -v "${dockerRepoPath}:/src" -w /src $dockerImage `
- sh -c "dotnet build src/MaksIT.Core -c Release --nologo -v q && dotnet build src/MaksIT.Core.Tests -c Release --nologo -v q && dotnet test src/MaksIT.Core.Tests -c Release --nologo -v q --no-build --logger 'trx;LogFileName=/src/$linuxTestResultFile'" 2>&1 | Out-String
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Host $dockerTestOutput
- Write-Error "Linux build/tests failed. Aborting release process."
- exit 1
-}
-
-# Parse Docker test results
-if ($dockerTestOutput -match "Passed:\s*(\d+)") { $script:linuxTestsPassed = [int]$Matches[1] } else { $script:linuxTestsPassed = 0 }
-if ($dockerTestOutput -match "Failed:\s*(\d+)") { $script:linuxTestsFailed = [int]$Matches[1] } else { $script:linuxTestsFailed = 0 }
-if ($dockerTestOutput -match "Skipped:\s*(\d+)") { $script:linuxTestsSkipped = [int]$Matches[1] } else { $script:linuxTestsSkipped = 0 }
-$script:linuxTestsTotal = $script:linuxTestsPassed + $script:linuxTestsFailed + $script:linuxTestsSkipped
-
-Write-Host " Tests: $script:linuxTestsPassed passed, $script:linuxTestsFailed failed, $script:linuxTestsSkipped skipped" -ForegroundColor Green
-Complete-Step "OK"
-
-# Clean up test results directory
-if (Test-Path $testResultsDir) {
- Remove-Item -Path $testResultsDir -Recurse -Force -ErrorAction SilentlyContinue
-}
-
-# ==============================================================================
-# PACK (rebuild for Windows after Docker overwrote bin/obj)
-# ==============================================================================
-
-Start-Step "Rebuilding for package (Windows)"
-# Docker tests may have overwritten bin/obj with Linux artifacts, rebuild for Windows
-dotnet build $projectDir -c Release --nologo -v q
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Rebuild for packaging failed."
- exit 1
-}
-
-Complete-Step "OK"
-
-Start-Step "Creating NuGet package"
-dotnet pack $projectDir -c Release --no-build --nologo -v q
-
-if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "dotnet pack failed."
- exit 1
-}
-
-# Look for the .nupkg and .snupkg files
-$packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1
-$symbolsFile = Get-ChildItem -Path $outputDir -Filter "*.snupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1
-
-if (-not $packageFile) {
- Complete-Step "FAIL"
- Write-Error "Package creation failed. No .nupkg file found."
- exit 1
-}
-
-# Get package size
-$packageSize = "{0:N2} KB" -f ($packageFile.Length / 1KB)
-Write-Host " Package: $($packageFile.Name) ($packageSize)"
-if ($symbolsFile) {
- $symbolsSize = "{0:N2} KB" -f ($symbolsFile.Length / 1KB)
- Write-Host " Symbols: $($symbolsFile.Name) ($symbolsSize)"
-}
-
-Complete-Step "OK"
-
-# ==============================================================================
-# DRY RUN SUMMARY / CONFIRMATION PROMPT
-# ==============================================================================
-
-if ($DryRun) {
- # Show timing summary
- Show-TimingSummary
-
- Write-Host ""
- Write-Host "=========================================="
- Write-Host "DRY RUN COMPLETE - v$version"
- Write-Host "=========================================="
- Write-Host ""
- Write-Host "Validation Results:"
- Write-Host " [OK] Prerequisites (dotnet, git, gh, docker)"
- Write-Host " [OK] Working directory clean" -ForegroundColor Green
- Write-Host " [OK] Tag $tag on branch '$releaseBranch'" -ForegroundColor Green
- if ($gitHubEnabled) {
- Write-Host " [OK] GitHub CLI authenticated" -ForegroundColor Green
- }
- else {
- Write-Host " [--] GitHub releases disabled" -ForegroundColor DarkGray
- }
- if ($hasVulnerabilities) {
- Write-Host " [WARN] Vulnerable packages found (review recommended)" -ForegroundColor Yellow
- }
- else {
- Write-Host " [OK] No vulnerable packages" -ForegroundColor Green
- }
- Write-Host ""
- Write-Host "Build Information:"
- Write-Host " Target framework: $targetFramework"
- Write-Host " Package: $($packageFile.Name) ($packageSize)"
- Write-Host " Release commit: $shortCommit (tag $tag)" -ForegroundColor Green
- Write-Host ""
- Write-Host "Test Results:"
- Write-Host " Windows: $script:windowsTestsPassed passed, $script:windowsTestsFailed failed, $script:windowsTestsSkipped skipped" -ForegroundColor Green
- Write-Host " Linux: $script:linuxTestsPassed passed, $script:linuxTestsFailed failed, $script:linuxTestsSkipped skipped" -ForegroundColor Green
- $totalTests = $script:windowsTestsTotal + $script:linuxTestsTotal
- Write-Host " Total: $totalTests tests across 2 platforms" -ForegroundColor Cyan
- if ($script:codeCoverage -gt 0) {
- $coverageColor = if ($coverageThreshold -gt 0 -and $script:codeCoverage -ge $coverageThreshold) { "Green" } elseif ($coverageThreshold -gt 0) { "Yellow" } else { "Cyan" }
- Write-Host " Coverage: $script:codeCoverage% line, $script:branchCoverage% branch" -ForegroundColor $coverageColor
- if ($coverageThreshold -gt 0) {
- Write-Host " Threshold: $coverageThreshold% ($(if ($script:codeCoverage -ge $coverageThreshold) { 'PASSED' } else { 'FAILED' }))" -ForegroundColor $coverageColor
- }
- }
- Write-Host ""
- Write-Host "Pending Features:"
- if ($packageSigningEnabled -and $packageSigningCertPath -and (Test-Path $packageSigningCertPath)) {
- Write-Host " [READY] Package Signing - Certificate configured" -ForegroundColor Green
- }
- else {
- Write-Host " [TODO] Package Signing - Enable in scriptsettings.json" -ForegroundColor DarkGray
- }
- if ($emailEnabled -and $emailSmtpServer -and $emailFrom -and $emailTo) {
- Write-Host " [READY] Email Notification - SMTP configured" -ForegroundColor Green
- }
- else {
- Write-Host " [TODO] Email Notification - Enable in scriptsettings.json" -ForegroundColor DarkGray
- }
- if ($coverageThreshold -gt 0) {
- Write-Host " [ACTIVE] Code Coverage - Threshold: $coverageThreshold%" -ForegroundColor Green
- }
- else {
- Write-Host " [INFO] Code Coverage - Collected but no threshold set" -ForegroundColor DarkGray
- }
- Write-Host ""
- Write-Host "If this were a real release, it would:"
- $itemNum = 1
- if (-not $script:nugetAlreadyReleased) {
- Write-Host " $itemNum. Push $($packageFile.Name) to NuGet.org"
- $itemNum++
- }
- if ($gitHubEnabled -and -not $script:githubAlreadyReleased) {
- Write-Host " $itemNum. Push tag $tag to remote (if not there)"
- $itemNum++
- Write-Host " $itemNum. Create GitHub release with assets"
- }
-
- # Show what would be skipped
- if ($script:nugetAlreadyReleased -or ($gitHubEnabled -and $script:githubAlreadyReleased)) {
- Write-Host ""
- Write-Host "Would be skipped (already released):" -ForegroundColor DarkGray
- if ($script:nugetAlreadyReleased) {
- Write-Host " - NuGet.org (version already exists)" -ForegroundColor DarkGray
- }
- if ($gitHubEnabled -and $script:githubAlreadyReleased) {
- Write-Host " - GitHub (release already exists)" -ForegroundColor DarkGray
- }
- }
-
- Write-Host ""
- Write-Host "Run without -DryRun to perform the actual release." -ForegroundColor Green
- exit 0
-}
-
-# Check if there's anything to do
-$hasWorkToDo = $false
-$workItems = @()
-
-if (-not $script:nugetAlreadyReleased) {
- $hasWorkToDo = $true
- $workItems += "Push package to NuGet.org"
-}
-
-if ($gitHubEnabled -and -not $script:githubAlreadyReleased) {
- $hasWorkToDo = $true
- $workItems += "Create GitHub release with tag v$version"
-}
-
-if (-not $hasWorkToDo) {
- Write-Host ""
- Write-Host "=========================================="
- Write-Host "NOTHING TO RELEASE" -ForegroundColor Yellow
- Write-Host "=========================================="
- Write-Host ""
- Write-Host "Version $version is already released on:" -ForegroundColor Yellow
- if ($script:nugetAlreadyReleased) {
- Write-Host " - NuGet.org" -ForegroundColor Green
- }
- if ($gitHubEnabled -and $script:githubAlreadyReleased) {
- Write-Host " - GitHub" -ForegroundColor Green
- }
- Write-Host ""
- Write-Host "To release a new version:" -ForegroundColor Gray
- Write-Host " 1. Update version in csproj" -ForegroundColor Gray
- Write-Host " 2. Run Generate-Changelog.ps1" -ForegroundColor Gray
- Write-Host " 3. Commit and tag: git tag v{new-version}" -ForegroundColor Gray
- Write-Host " 4. Run this script again" -ForegroundColor Gray
- exit 0
-}
-
-Write-Host ""
-Write-Host "=========================================="
-Write-Host "Ready to release v$version"
-Write-Host "=========================================="
-Write-Host "This will:"
-$itemNum = 1
-foreach ($item in $workItems) {
- Write-Host " $itemNum. $item"
- $itemNum++
-}
-
-# Show what will be skipped
-if ($script:nugetAlreadyReleased -or ($gitHubEnabled -and $script:githubAlreadyReleased)) {
- Write-Host ""
- Write-Host "Skipping (already released):" -ForegroundColor DarkGray
- if ($script:nugetAlreadyReleased) {
- Write-Host " - NuGet.org (version already exists)" -ForegroundColor DarkGray
- }
- if ($gitHubEnabled -and $script:githubAlreadyReleased) {
- Write-Host " - GitHub (release already exists)" -ForegroundColor DarkGray
- }
-}
-
-Write-Host ""
-$confirm = Read-Host "Proceed with release? (y/n)"
-if ($confirm -ne 'y' -and $confirm -ne 'Y') {
- Write-Host "Release cancelled."
- exit 0
-}
-
-# ==============================================================================
-# NUGET PUBLISH
-# ==============================================================================
-
-if ($script:nugetAlreadyReleased) {
- Write-Host ""
- Write-Host "Skipping NuGet publish (version $version already exists)" -ForegroundColor Yellow
-}
-else {
- Start-Step "Pushing to NuGet.org"
- dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
-
- if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Failed to push the package to NuGet."
- exit 1
- }
-
- Complete-Step "OK"
-}
-
-# ==============================================================================
-# GITHUB RELEASE
-# ==============================================================================
-
-if ($gitHubEnabled) {
- if ($script:githubAlreadyReleased) {
- Write-Host ""
- Write-Host "Skipping GitHub release (release $tag already exists)" -ForegroundColor Yellow
- }
- else {
- Start-Step "Creating GitHub release"
- Write-Host " Tag: $tag -> $shortCommit"
-
- # Push tag to remote if not already there
- $remoteTag = git ls-remote --tags origin $tag 2>$null
- if (-not $remoteTag) {
- Write-Host " Pushing tag to remote..."
- git push origin $tag
- if ($LASTEXITCODE -ne 0) {
- Write-Error "Failed to push git tag."
- exit 1
- }
- }
- else {
- Write-Host " Tag already on remote"
- }
-
- # Build release assets list
- $releaseAssets = @($packageFile.FullName)
- if ($symbolsFile) {
- $releaseAssets += $symbolsFile.FullName
- }
-
- # Create GitHub release with assets
- Write-Host "Creating GitHub release: $releaseName"
-
- gh release create $tag @releaseAssets `
- --repo $gitHubRepo `
- --title "$releaseName" `
- --notes "$releaseNotes"
-
- if ($LASTEXITCODE -ne 0) {
- Complete-Step "FAIL"
- Write-Error "Failed to create GitHub release for tag $tag."
- exit 1
- }
-
- Complete-Step "OK"
- }
-}
-else {
- Write-Host ""
- Write-Host "Skipping GitHub release (disabled in settings)" -ForegroundColor Yellow
-}
-
-# ==============================================================================
-# COMPLETION
-# ==============================================================================
-
-# Show timing summary
-Show-TimingSummary
-
-Write-Host ""
-Write-Host "=========================================="
-Write-Host "RELEASE COMPLETED SUCCESSFULLY!" -ForegroundColor Green
-Write-Host "=========================================="
-Write-Host ""
-Write-Host "Version: $version"
-Write-Host "Package: $($packageFile.Name) ($packageSize)"
-Write-Host ""
-Write-Host "Test Results:"
-Write-Host " Windows: $script:windowsTestsPassed passed" -ForegroundColor Green
-Write-Host " Linux: $script:linuxTestsPassed passed" -ForegroundColor Green
-if ($script:codeCoverage -gt 0) {
- Write-Host " Coverage: $script:codeCoverage% line, $script:branchCoverage% branch" -ForegroundColor Cyan
-}
-
-Write-Host ""
-Write-Host "Links:"
-Write-Host " NuGet: https://www.nuget.org/packages/MaksIT.Core/$version"
-if ($gitHubEnabled) {
- Write-Host " GitHub: https://github.com/$gitHubRepo/releases/tag/$tag"
-}
-Write-Host ""
-
-# ==============================================================================
-# PACKAGE SIGNING (TODO)
-# ==============================================================================
-
-if ($packageSigningEnabled -and $packageSigningCertPath -and (Test-Path $packageSigningCertPath)) {
- Start-Step "Signing NuGet package"
- try {
- $signArgs = @(
- "nuget", "sign", $packageFile.FullName,
- "--certificate-path", $packageSigningCertPath,
- "--timestamper", $packageSigningTimestamper
- )
- if ($packageSigningCertPassword) {
- $signArgs += "--certificate-password"
- $signArgs += $packageSigningCertPassword
- }
- & dotnet @signArgs
- if ($LASTEXITCODE -eq 0) {
- Write-Host " Package signed successfully" -ForegroundColor Green
- Complete-Step "OK"
- }
- else {
- Write-Host " Package signing failed (continuing without signature)" -ForegroundColor Yellow
- Complete-Step "WARN"
- }
- }
- catch {
- Write-Host " Package signing error: $_" -ForegroundColor Yellow
- Complete-Step "WARN"
- }
-}
-else {
- Write-Host ""
- Write-Host "[TODO] Package Signing - Not configured" -ForegroundColor DarkGray
- Write-Host " Set packageSigning.enabled = true in scriptsettings.json" -ForegroundColor DarkGray
-}
-
-# ==============================================================================
-# EMAIL NOTIFICATION (TODO)
-# ==============================================================================
-
-if ($emailEnabled -and $emailSmtpServer -and $emailFrom -and $emailTo) {
- Start-Step "Sending email notification"
- try {
- $gitHubLine = if ($gitHubEnabled) { "GitHub: https://github.com/$gitHubRepo/releases/tag/$tag`n" } else { "" }
- $emailBody = @"
-MaksIT.Core Release $version completed successfully.
-
-Package: $($packageFile.Name)
-NuGet: https://www.nuget.org/packages/MaksIT.Core/$version
-$gitHubLine
-Test Results:
-- Windows: $script:windowsTestsPassed passed
-- Linux: $script:linuxTestsPassed passed
-"@
- $emailParams = @{
- From = $emailFrom
- To = $emailTo
- Subject = "MaksIT.Core v$version Released"
- Body = $emailBody
- SmtpServer = $emailSmtpServer
- Port = $emailSmtpPort
- UseSsl = $emailUseSsl
- }
-
- # Add credentials if SMTP password is set
- if ($smtpPassword) {
- $securePassword = ConvertTo-SecureString $smtpPassword -AsPlainText -Force
- $credential = New-Object System.Management.Automation.PSCredential($emailFrom, $securePassword)
- $emailParams.Credential = $credential
- }
-
- Send-MailMessage @emailParams
- Write-Host " Email sent to $emailTo" -ForegroundColor Green
- Complete-Step "OK"
- }
- catch {
- Write-Host " Email sending failed: $_" -ForegroundColor Yellow
- Complete-Step "WARN"
- }
-}
-else {
- Write-Host ""
- Write-Host "[TODO] Email Notification - Not configured" -ForegroundColor DarkGray
- Write-Host " Set emailNotification.enabled = true in scriptsettings.json" -ForegroundColor DarkGray
-}
-
-# ==============================================================================
-# CODE COVERAGE STATUS
-# ==============================================================================
-
-Write-Host ""
-if ($script:codeCoverage -gt 0) {
- if ($coverageThreshold -gt 0) {
- Write-Host "[ACTIVE] Code Coverage: $script:codeCoverage% (threshold: $coverageThreshold%)" -ForegroundColor Green
- }
- else {
- Write-Host "[INFO] Code Coverage: $script:codeCoverage% (no threshold enforced)" -ForegroundColor Cyan
- Write-Host " Set `$coverageThreshold > 0 to enforce minimum coverage" -ForegroundColor DarkGray
- }
-}
-else {
- Write-Host "[SKIP] Code Coverage - Not collected" -ForegroundColor DarkGray
- Write-Host " Ensure coverlet.collector is installed in test project" -ForegroundColor DarkGray
-}
-
-Write-Host ""
-
-# Open release pages in browser
-Write-Host "Opening release pages in browser..."
-Start-Process "https://www.nuget.org/packages/MaksIT.Core/$version"
-if ($gitHubEnabled) {
- Start-Process "https://github.com/$gitHubRepo/releases/tag/$tag"
-}
diff --git a/src/scripts/scriptsettings.json b/src/scripts/scriptsettings.json
deleted file mode 100644
index d807f06..0000000
--- a/src/scripts/scriptsettings.json
+++ /dev/null
@@ -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": ""
- }
-}
diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat
new file mode 100644
index 0000000..a2c4bda
--- /dev/null
+++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat
@@ -0,0 +1,3 @@
+@echo off
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
+pause
\ No newline at end of file
diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
new file mode 100644
index 0000000..3f1e001
--- /dev/null
+++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
@@ -0,0 +1,220 @@
+<#
+.SYNOPSIS
+ Amends the latest tagged commit and force-pushes updated branch and tag.
+
+.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
diff --git a/utils/Force-AmendTaggedCommit/scriptsettings.json b/utils/Force-AmendTaggedCommit/scriptsettings.json
new file mode 100644
index 0000000..df73911
--- /dev/null
+++ b/utils/Force-AmendTaggedCommit/scriptsettings.json
@@ -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"
+ }
+ }
+}
diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat
new file mode 100644
index 0000000..4569dab
--- /dev/null
+++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat
@@ -0,0 +1,3 @@
+@echo off
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
+pause
diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
new file mode 100644
index 0000000..5c4bdde
--- /dev/null
+++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
@@ -0,0 +1,232 @@
+<#
+.SYNOPSIS
+ Runs tests, collects coverage, and generates SVG badges for README.
+
+.DESCRIPTION
+ This script runs unit tests via TestRunner.psm1, then generates shields.io-style
+ SVG badges for line, branch, and method coverage.
+ Optional HTML report generation is controlled by scriptsettings.json (openReport).
+
+ Configuration is stored in scriptsettings.json:
+ - openReport : Generate and open full HTML report (true/false)
+ - 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 @"
+
+"@
+}
+
+#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
diff --git a/utils/Generate-CoverageBadges/scriptsettings.json b/utils/Generate-CoverageBadges/scriptsettings.json
new file mode 100644
index 0000000..23a025c
--- /dev/null
+++ b/utils/Generate-CoverageBadges/scriptsettings.json
@@ -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."
+ }
+}
diff --git a/utils/GitTools.psm1 b/utils/GitTools.psm1
new file mode 100644
index 0000000..5b795c9
--- /dev/null
+++ b/utils/GitTools.psm1
@@ -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
diff --git a/utils/Logging.psm1 b/utils/Logging.psm1
new file mode 100644
index 0000000..28be784
--- /dev/null
+++ b/utils/Logging.psm1
@@ -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
diff --git a/utils/Release-NuGetPackage/Release-NuGetPackage.bat b/utils/Release-NuGetPackage/Release-NuGetPackage.bat
new file mode 100644
index 0000000..7fa08e9
--- /dev/null
+++ b/utils/Release-NuGetPackage/Release-NuGetPackage.bat
@@ -0,0 +1,3 @@
+@echo off
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
+pause
\ No newline at end of file
diff --git a/utils/Release-NuGetPackage/Release-NuGetPackage.ps1 b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1
new file mode 100644
index 0000000..cd8ac59
--- /dev/null
+++ b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1
@@ -0,0 +1,766 @@
+<#
+.SYNOPSIS
+ Builds, tests, packs, and publishes MaksIT.Core to NuGet and GitHub releases.
+
+.DESCRIPTION
+ This script automates the release process for MaksIT.Core library.
+ The script is IDEMPOTENT - you can safely re-run it if any step fails.
+ It will skip already-completed steps (NuGet and GitHub) and only create what's missing.
+ GitHub repository target can be configured explicitly in scriptsettings.json.
+
+ Features:
+ - Validates environment and prerequisites
+ - Checks if version already exists on NuGet.org (skips if released)
+ - Checks if GitHub release exists (skips if released)
+ - Scans for vulnerable packages (security check)
+ - Builds and tests the project (Windows + Linux via Docker)
+ - Collects code coverage with Coverlet (threshold enforcement optional)
+ - Generates test result artifacts (TRX format) and coverage reports
+ - Displays test results with pass/fail counts and coverage percentage
+ - Publishes to NuGet.org
+ - Creates a GitHub release with changelog and package assets
+ - Shows timing summary for all steps
+
+.REQUIREMENTS
+ Environment Variables:
+ - NUGET_MAKS_IT : NuGet.org API key for publishing packages
+ - GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope)
+
+ Tools (Required):
+ - dotnet CLI : For building, testing, and packing
+ - git : For version control operations
+ - gh (GitHub CLI) : For creating GitHub releases
+ - docker : For cross-platform Linux testing
+
+.WORKFLOW
+ 1. VALIDATION PHASE
+ - Check required environment variables (NuGet key, GitHub token)
+ - Check required tools are installed (dotnet, git, gh, docker)
+ - Verify no uncommitted changes in working directory
+ - Authenticate GitHub CLI
+
+ 2. VERSION & RELEASE CHECK PHASE (Idempotent)
+ - Read latest version from CHANGELOG.md
+ - Find commit with matching version tag
+ - Validate tag is on configured release branch (from scriptsettings.json)
+ - Check if already released on NuGet.org (mark for skip if yes)
+ - Check if GitHub release exists (mark for skip if yes)
+ - Read target framework from MaksIT.Core.csproj
+ - Extract release notes from CHANGELOG.md for current version
+
+ 3. SECURITY SCAN
+ - Check for vulnerable packages (dotnet list package --vulnerable)
+ - Fail or warn based on $failOnVulnerabilities setting
+
+ 4. BUILD & TEST PHASE
+ - Clean previous builds (delete bin/obj folders)
+ - Restore NuGet packages
+ - Windows: Build main project -> Build test project -> Run tests with coverage
+ - Analyze code coverage (fail if below threshold when configured)
+ - Linux (Docker): Build main project -> Build test project -> Run tests (TRX report)
+ - Rebuild for Windows (Docker may overwrite bin/obj)
+ - Create NuGet package (.nupkg) and symbols (.snupkg)
+ - All steps are timed for performance tracking
+
+ 5. CONFIRMATION PHASE
+ - Display release summary
+ - Prompt user for confirmation before proceeding
+
+ 6. NUGET RELEASE PHASE (Idempotent)
+ - Skip if version already exists on NuGet.org
+ - Otherwise, push package to NuGet.org
+
+ 7. GITHUB RELEASE PHASE (Idempotent)
+ - Skip if release already exists
+ - Push tag to remote if not already there
+ - Create GitHub release with:
+ * Release notes from CHANGELOG.md
+ * .nupkg and .snupkg as downloadable assets
+
+ 8. COMPLETION PHASE
+ - Display timing summary for all steps
+ - Display test results summary
+ - Display success summary with links
+ - Open NuGet and GitHub release pages in browser
+ - TODO: Email notification (template provided)
+ - TODO: Package signing (template provided)
+
+.USAGE
+ Before running:
+ 1. Ensure Docker Desktop is running (for Linux tests)
+ 2. Update version in MaksIT.Core.csproj
+ 3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md
+ 4. Review and commit all changes
+ 5. Create version tag: git tag v1.x.x
+ 6. Run: .\Release-NuGetPackage.ps1
+
+ Note: The script finds the commit with the tag matching CHANGELOG.md version.
+ You can run it from any branch/commit - it releases the tagged commit.
+
+ Re-run release (idempotent - skips NuGet/GitHub if already released):
+ .\Release-NuGetPackage.ps1
+
+ Generate changelog and update LICENSE year:
+ .\Generate-Changelog.ps1
+
+.CONFIGURATION
+ All settings are stored in scriptsettings.json:
+ - packageSigning: Code signing certificate configuration
+ - emailNotification: SMTP settings for release notifications
+
+.NOTES
+ Author: Maksym Sadovnychyy (MAKS-IT)
+ Repository: https://github.com/MAKS-IT-COM/maksit-core
+#>
+
+# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
+# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
+# - release branch -> Full release to GitHub (tag required, clean working directory)
+
+# Get the directory of the current script (for loading settings and relative paths)
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+#region Import Modules
+
+# Import TestRunner module
+$utilsDir = Split-Path $scriptDir -Parent
+
+$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1"
+if (-not (Test-Path $testRunnerModulePath)) {
+ Write-Error "TestRunner module not found at: $testRunnerModulePath"
+ exit 1
+}
+
+Import-Module $testRunnerModulePath -Force
+
+# Import ScriptConfig module
+$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
+if (-not (Test-Path $scriptConfigModulePath)) {
+ Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
+ exit 1
+}
+
+Import-Module $scriptConfigModulePath -Force
+
+# Import Logging module
+$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
+if (-not (Test-Path $loggingModulePath)) {
+ Write-Error "Logging module not found at: $loggingModulePath"
+ exit 1
+}
+
+Import-Module $loggingModulePath -Force
+
+
+# Import GitTools module
+$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
+if (-not (Test-Path $gitToolsModulePath)) {
+ Write-Error "GitTools module not found at: $gitToolsModulePath"
+ exit 1
+}
+
+Import-Module $gitToolsModulePath -Force
+
+#endregion
+
+#region Load Settings
+$settings = Get-ScriptSettings -ScriptDir $scriptDir
+
+#endregion
+
+#region Configuration
+
+# GitHub configuration
+$githubReleseEnabled = $settings.github.enabled
+$githubTokenEnvVar = $settings.github.githubToken
+$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
+$githubRepositorySetting = $settings.github.repository
+
+# NuGet configuration
+$nugetReleseEnabled = $settings.nuget.enabled
+$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey
+$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
+$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" }
+
+# Paths from settings (resolve relative to script directory)
+$csprojPaths = @()
+$rawCsprojPaths = @()
+
+if ($settings.paths.csprojPaths) {
+ if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) {
+ $rawCsprojPaths += $settings.paths.csprojPaths
+ }
+ else {
+ $rawCsprojPaths += $settings.paths.csprojPaths
+ }
+}
+else {
+ Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json."
+ exit 1
+}
+
+foreach ($path in $rawCsprojPaths) {
+ if ([string]::IsNullOrWhiteSpace($path)) {
+ continue
+ }
+
+ $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
+ $csprojPaths += $resolvedPath
+}
+
+if ($csprojPaths.Count -eq 0) {
+ Write-Error "No valid csproj paths configured in scriptsettings.json."
+ exit 1
+}
+
+$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir))
+$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
+$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
+$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
+$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
+
+# Release naming patterns
+$zipNamePattern = $settings.release.zipNamePattern
+$releaseTitlePattern = $settings.release.releaseTitlePattern
+
+# Branch configuration
+$releaseBranch = $settings.branches.release
+$devBranch = $settings.branches.dev
+
+#endregion
+
+#region Helpers
+
+# Helper: extract a csproj property (first match)
+function Get-CsprojPropertyValue {
+ param(
+ [Parameter(Mandatory=$true)][xml]$csproj,
+ [Parameter(Mandatory=$true)][string]$propertyName
+ )
+
+ $propNode = $csproj.Project.PropertyGroup |
+ Where-Object { $_.$propertyName } |
+ Select-Object -First 1
+
+ if ($propNode) {
+ return $propNode.$propertyName
+ }
+
+ return $null
+}
+
+# Helper: resolve output assembly name for published exe
+function Resolve-ProjectExeName {
+ param(
+ [Parameter(Mandatory=$true)][string]$projPath
+ )
+
+ [xml]$csproj = Get-Content $projPath
+ $assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
+ if ($assemblyName) {
+ return $assemblyName
+ }
+
+ return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
+}
+
+# Helper: check for uncommitted changes
+function Assert-WorkingTreeClean {
+ param(
+ [Parameter(Mandatory = $true)]
+ [bool]$IsReleaseBranch
+ )
+
+ $gitStatus = Get-GitStatusShort
+ if ($gitStatus) {
+ if ($IsReleaseBranch) {
+ Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
+ Write-Log -Level "WARN" -Message "Uncommitted files:"
+ $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
+ exit 1
+ }
+ else {
+ Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
+ }
+ }
+ else {
+ Write-Log -Level "OK" -Message " Working directory is clean."
+ }
+}
+
+# Helper: read versions from csproj files
+function Get-CsprojVersions {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$CsprojPaths
+ )
+
+ Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..."
+ $projectVersions = @{}
+
+ foreach ($projPath in $CsprojPaths) {
+ if (-not (Test-Path $projPath -PathType Leaf)) {
+ Write-Error "Csproj file not found at: $projPath"
+ exit 1
+ }
+
+ if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") {
+ Write-Error "Configured path is not a .csproj file: $projPath"
+ exit 1
+ }
+
+ [xml]$csproj = Get-Content $projPath
+ $version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
+
+ if (-not $version) {
+ Write-Error "Version not found in $projPath"
+ exit 1
+ }
+
+ $projectVersions[$projPath] = $version
+ Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version"
+ }
+
+ return $projectVersions
+}
+
+# Helper: resolve GitHub repository (owner/repo) from settings override or remote URL
+function Resolve-GitHubRepository {
+ param(
+ [Parameter(Mandatory = $false)]
+ [string]$RepositorySetting
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($RepositorySetting)) {
+ $value = $RepositorySetting.Trim()
+
+ if ($value -match '^https?://github\.com/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$') {
+ return "$($Matches['owner'])/$($Matches['repo'])"
+ }
+
+ if ($value -match '^(?[^/]+)/(?[^/]+)$') {
+ return "$($Matches['owner'])/$($Matches['repo'])"
+ }
+
+ Write-Error "Invalid github.repository format '$value'. Use 'owner/repo' or 'https://github.com/owner/repo'."
+ exit 1
+ }
+
+ $remoteUrl = git config --get remote.origin.url
+ if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
+ Write-Error "Could not determine git remote origin URL. Configure github.repository in scriptsettings.json."
+ exit 1
+ }
+
+ if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") {
+ return "$($Matches['owner'])/$($Matches['repo'])"
+ }
+
+ Write-Error "Could not parse repository from remote URL: $remoteUrl. Configure github.repository in scriptsettings.json."
+ exit 1
+}
+
+#endregion
+
+#region Validate CLI Dependencies
+
+Assert-Command dotnet
+Assert-Command git
+Assert-Command docker
+# gh command check deferred until after branch detection (only needed on release branch)
+
+#endregion
+
+#region Main
+
+Write-Log -Level "STEP" -Message "=================================================="
+Write-Log -Level "STEP" -Message "RELEASE BUILD"
+Write-Log -Level "STEP" -Message "=================================================="
+
+#region Preflight
+
+$isDevBranch = $false
+$isReleaseBranch = $false
+
+# 1. Detect current branch and determine release mode
+$currentBranch = Get-CurrentBranch
+
+$isDevBranch = $currentBranch -eq $devBranch
+$isReleaseBranch = $currentBranch -eq $releaseBranch
+
+if (-not $isDevBranch -and -not $isReleaseBranch) {
+ Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
+ exit 1
+}
+
+# 2. Check for uncommitted changes (required on release branch, allowed on dev)
+Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch
+
+# 3. Get version from csproj (source of truth)
+$projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths
+
+# Use the first project's version as the release version
+$version = $projectVersions[$csprojPaths[0]]
+
+# 4. Handle tag based on branch
+if ($isReleaseBranch) {
+ # Release branch: tag is required and must match version
+ $tag = Get-CurrentCommitTag -Version $version
+
+ if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
+ Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
+ exit 1
+ }
+
+ $tagVersion = $Matches[1]
+
+ if ($tagVersion -ne $version) {
+ Write-Error "Tag version ($tagVersion) does not match csproj version ($version)."
+ Write-Log -Level "WARN" -Message " Either update the tag or the csproj version."
+ exit 1
+ }
+
+ Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)"
+}
+else {
+ # Dev branch: no tag required, use version from csproj
+ $tag = "v$version"
+ Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)."
+}
+
+# 5. Verify CHANGELOG.md has matching version entry
+Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..."
+if (-not (Test-Path $changelogPath)) {
+ Write-Error "CHANGELOG.md not found at: $changelogPath"
+ exit 1
+}
+
+$changelog = Get-Content $changelogPath -Raw
+
+if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
+ Write-Error "No version entry found in CHANGELOG.md"
+ exit 1
+}
+
+$changelogVersion = $Matches[1]
+
+if ($changelogVersion -ne $version) {
+ Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
+ Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version."
+ exit 1
+}
+
+Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion"
+
+
+
+Write-Log -Level "OK" -Message "All pre-flight checks passed!"
+
+#endregion
+
+#region Test
+
+Write-Log -Level "STEP" -Message "Running tests..."
+
+# Run tests using TestRunner module
+$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent
+
+if (-not $testResult.Success) {
+ Write-Error "Tests failed. Release aborted."
+ Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)"
+ exit 1
+}
+
+Write-Log -Level "OK" -Message " All tests passed!"
+Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
+Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
+Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
+
+#endregion
+
+#region Build And Publish
+
+# 7. Prepare staging directory
+Write-Log -Level "STEP" -Message "Preparing staging directory..."
+if (Test-Path $stagingDir) {
+ Remove-Item $stagingDir -Recurse -Force
+}
+
+New-Item -ItemType Directory -Path $stagingDir | Out-Null
+
+$binDir = Join-Path $stagingDir "bin"
+
+# 8. Publish the project to staging/bin
+
+Write-Log -Level "STEP" -Message "Publishing projects to bin folder..."
+$publishSuccess = $true
+$publishedProjects = @()
+
+foreach ($projPath in $csprojPaths) {
+ $projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
+ $projBinDir = Join-Path $binDir $projName
+
+ dotnet publish $projPath -c Release -o $projBinDir
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet publish failed for $projName."
+ $publishSuccess = $false
+ }
+ else {
+ $exeBaseName = Resolve-ProjectExeName -projPath $projPath
+ $publishedProjects += [PSCustomObject]@{
+ ProjPath = $projPath
+ ProjName = $projName
+ BinDir = $projBinDir
+ ExeBaseName = $exeBaseName
+ }
+
+ Write-Log -Level "OK" -Message " Published $projName successfully to: $projBinDir"
+ }
+}
+
+if (-not $publishSuccess) {
+ exit 1
+}
+
+
+# 12. Prepare release directory
+if (!(Test-Path $releaseDir)) {
+ New-Item -ItemType Directory -Path $releaseDir | Out-Null
+}
+
+
+# 13. Create zip file
+$zipName = $zipNamePattern
+$zipName = $zipName -replace '\{version\}', $version
+$zipPath = Join-Path $releaseDir $zipName
+
+if (Test-Path $zipPath) {
+ Remove-Item $zipPath -Force
+}
+
+Write-Log -Level "STEP" -Message "Creating archive $zipName..."
+Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
+
+if (-not (Test-Path $zipPath)) {
+ Write-Error "Failed to create archive $zipPath"
+ exit 1
+}
+
+Write-Log -Level "OK" -Message " Archive created: $zipPath"
+
+# 14. Pack NuGet package and resolve produced .nupkg file
+$packageProjectPath = $csprojPaths[0]
+Write-Log -Level "STEP" -Message "Packing NuGet package..."
+dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet pack failed for $packageProjectPath."
+ exit 1
+}
+
+$packageFile = Get-ChildItem -Path $releaseDir -Filter "*.nupkg" |
+ Where-Object {
+ $_.Name -like "*$version*.nupkg" -and
+ $_.Name -notlike "*.symbols.nupkg" -and
+ $_.Name -notlike "*.snupkg"
+ } |
+ Sort-Object LastWriteTime -Descending |
+ Select-Object -First 1
+
+if (-not $packageFile) {
+ Write-Error "Could not locate generated NuGet package for version $version in: $releaseDir"
+ exit 1
+}
+
+Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
+
+# 15. Extract release notes from CHANGELOG.md
+Write-Log -Level "STEP" -Message "Extracting release notes..."
+$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
+$match = [regex]::Match($changelog, $pattern)
+
+if (-not $match.Success) {
+ Write-Error "Changelog entry for version $version not found."
+ exit 1
+}
+
+$releaseNotes = $match.Value.Trim()
+Write-Log -Level "OK" -Message " Release notes extracted."
+
+# 16. Resolve repository info for GitHub release
+$repo = Resolve-GitHubRepository -RepositorySetting $githubRepositorySetting
+
+$releaseName = $releaseTitlePattern -replace '\{version\}', $version
+
+Write-Log -Level "STEP" -Message "Release Summary:"
+Write-Log -Level "INFO" -Message " Repository: $repo"
+Write-Log -Level "INFO" -Message " Tag: $tag"
+Write-Log -Level "INFO" -Message " Title: $releaseName"
+
+# 17. Check if tag is pushed to remote (skip on dev branch)
+
+if (-not $isDevBranch) {
+
+ Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
+ $remoteTagExists = Test-RemoteTagExists -Tag $tag -Remote "origin"
+ if (-not $remoteTagExists) {
+ Write-Log -Level "WARN" -Message " Tag $tag not found on remote. Pushing..."
+ Push-TagToRemote -Tag $tag -Remote "origin"
+ }
+ else {
+ Write-Log -Level "OK" -Message " Tag exists on remote."
+ }
+
+
+
+ # Release to GitHub
+ if ($githubReleseEnabled) {
+
+ Write-Log -Level "STEP" -Message " Release branch ($releaseBranch) - will publish to GitHub."
+ Assert-Command gh
+
+ # 6. Check GitHub authentication
+
+ Write-Log -Level "INFO" -Message "Checking GitHub authentication..."
+ if (-not $githubToken) {
+ Write-Error "GitHub token is not set. Set '$githubTokenEnvVar' and rerun."
+ exit 1
+ }
+
+ # gh release subcommands do not support custom auth headers.
+ # Scope GH_TOKEN to this block so all gh commands authenticate with the configured token.
+ $previousGhToken = $env:GH_TOKEN
+ $env:GH_TOKEN = $githubToken
+
+ try {
+ $authTest = & gh api user 2>$null
+
+ if ($LASTEXITCODE -ne 0 -or -not $authTest) {
+ Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope."
+ exit 1
+ }
+ Write-Log -Level "OK" -Message " GitHub CLI authenticated."
+
+ # 18. Create or update GitHub release
+ Write-Log -Level "STEP" -Message "Creating GitHub release..."
+
+ # Check if release already exists
+ $releaseViewArgs = @(
+ "release", "view", $tag,
+ "--repo", $repo
+ )
+ & gh @releaseViewArgs 2>$null
+
+ if ($LASTEXITCODE -eq 0) {
+ Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..."
+ $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes")
+ & gh @releaseDeleteArgs
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to delete existing release $tag."
+ exit 1
+ }
+ }
+
+ # Create release using the existing tag
+ # Write release notes to a temp file to avoid shell interpretation issues with special characters
+ $notesFilePath = Join-Path $releaseDir "release-notes-temp.md"
+ [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false))
+
+ $createReleaseArgs = @(
+ "release", "create", $tag, $zipPath
+ "--repo", $repo
+ "--title", $releaseName
+ "--notes-file", $notesFilePath
+ )
+ & gh @createReleaseArgs
+
+ $ghExitCode = $LASTEXITCODE
+
+ # Cleanup temp notes file
+ if (Test-Path $notesFilePath) {
+ Remove-Item $notesFilePath -Force
+ }
+
+ if ($ghExitCode -ne 0) {
+ Write-Error "Failed to create GitHub release for tag $tag."
+ exit 1
+ }
+ }
+ finally {
+ if ($null -ne $previousGhToken) {
+ $env:GH_TOKEN = $previousGhToken
+ }
+ else {
+ Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue
+ }
+ }
+
+ Write-Log -Level "OK" -Message " GitHub release created successfully."
+ }
+ else {
+ Write-Log -Level "WARN" -Message "Skipping GitHub release (disabled)."
+ }
+
+
+ # Release to NuGet
+
+ if ($nugetReleseEnabled) {
+ Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
+ dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to push the package to NuGet."
+ exit 1
+ }
+
+ Write-Log -Level "OK" -Message " NuGet push completed."
+ }
+ else {
+ Write-Log -Level "WARN" -Message "Skipping NuGet publish (disabled)."
+ }
+
+}
+else {
+ Write-Log -Level "WARN" -Message "Skipping remote tag verification and GitHub release (dev branch)."
+}
+
+#endregion
+
+#region Cleanup
+if (Test-Path $stagingDir) {
+ Remove-Item $stagingDir -Recurse -Force
+ Write-Log -Level "INFO" -Message " Cleaned up staging directory."
+}
+
+if (Test-Path $testResultsDir) {
+ Remove-Item $testResultsDir -Recurse -Force
+ Write-Log -Level "INFO" -Message " Cleaned up test results directory."
+}
+#endregion
+
+#region Summary
+Write-Log -Level "OK" -Message "=================================================="
+if ($isDevBranch) {
+ Write-Log -Level "OK" -Message "DEV BUILD COMPLETE"
+}
+else {
+ Write-Log -Level "OK" -Message "RELEASE COMPLETE"
+}
+Write-Log -Level "OK" -Message "=================================================="
+
+if (-not $isDevBranch) {
+ Write-Log -Level "STEP" -Message "Release URL: https://github.com/$repo/releases/tag/$tag"
+}
+
+Write-Log -Level "INFO" -Message "Artifacts location: $releaseDir"
+
+if ($isDevBranch) {
+ Write-Log -Level "WARN" -Message "To publish to GitHub, switch to '$releaseBranch', merge dev, tag, and run this script again:"
+ Write-Log -Level "WARN" -Message " git checkout $releaseBranch"
+ Write-Log -Level "WARN" -Message " git merge dev"
+ Write-Log -Level "WARN" -Message " git tag v$version"
+ Write-Log -Level "WARN" -Message " .\Release-NuGetPackage.ps1"
+}
+
+#endregion
+
+#endregion
diff --git a/utils/Release-NuGetPackage/scriptsettings.json b/utils/Release-NuGetPackage/scriptsettings.json
new file mode 100644
index 0000000..d1675a9
--- /dev/null
+++ b/utils/Release-NuGetPackage/scriptsettings.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "https://json-schema.org/draft-07/schema",
+ "title": "Release NuGet Package Script Settings",
+ "description": "Configuration file for Release-NuGetPackage.ps1 script.",
+
+ "github": {
+ "enabled": true,
+ "githubToken": "GITHUB_MAKS_IT_COM",
+ "repository": "https://github.com/MAKS-IT-COM/maksit-core"
+ },
+
+ "nuget": {
+ "enabled": true,
+ "nugetApiKey": "NUGET_MAKS_IT",
+ "source": "https://api.nuget.org/v3/index.json"
+ },
+
+ "branches": {
+ "release": "main",
+ "dev": "dev"
+ },
+
+ "paths": {
+ "csprojPaths": [
+ "..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
+ ],
+ "testResultsDir": "..\\..\\testResults",
+ "stagingDir": "..\\..\\staging",
+ "releaseDir": "..\\..\\release",
+ "changelogPath": "..\\..\\CHANGELOG.md",
+ "testProject": "..\\..\\src\\MaksIT.Core.Tests"
+ },
+
+ "release": {
+ "zipNamePattern": "maksit.core-{version}.zip",
+ "releaseTitlePattern": "Release {version}"
+ },
+
+ "_comments": {
+ "github": {
+ "enabled": "Enable/disable GitHub release creation.",
+ "githubToken": "Environment variable name containing GitHub token used by gh CLI.",
+ "repository": "GitHub repository override used for releases (supports owner/repo or full GitHub URL)."
+ },
+ "nuget": {
+ "enabled": "Enable/disable NuGet publish step.",
+ "nugetApiKey": "Environment variable name containing NuGet API key.",
+ "source": "NuGet feed URL passed to dotnet nuget push."
+ },
+ "branches": {
+ "release": "Branch that requires tag and allows full publish flow.",
+ "dev": "Branch for local/dev build flow (no tag required)."
+ },
+ "paths": {
+ "csprojPaths": "List of project files used for version discovery and publish output.",
+ "testResultsDir": "Directory where test artifacts are written.",
+ "stagingDir": "Temporary staging directory before archive creation.",
+ "releaseDir": "Output directory for release archives and artifacts.",
+ "changelogPath": "Path to CHANGELOG.md used for version and release notes extraction.",
+ "testProject": "Test project path used by TestRunner."
+ },
+ "release": {
+ "zipNamePattern": "Archive name pattern. Supports {version} placeholder.",
+ "releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder."
+ }
+ }
+}
diff --git a/utils/ScriptConfig.psm1 b/utils/ScriptConfig.psm1
new file mode 100644
index 0000000..8b93dfc
--- /dev/null
+++ b/utils/ScriptConfig.psm1
@@ -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
diff --git a/utils/TestRunner.psm1 b/utils/TestRunner.psm1
new file mode 100644
index 0000000..5de475a
--- /dev/null
+++ b/utils/TestRunner.psm1
@@ -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