From 84e8a4d9e8c4680c1f92ba2299317b24c9adfd63 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sat, 21 Feb 2026 19:24:19 +0100 Subject: [PATCH] (feature): centralize release tooling, logging, and package/release flow --- .gitignore | 3 + CHANGELOG.md | 41 +- README.md | 2 + assets/badges/coverage-branches.svg | 21 + assets/badges/coverage-lines.svg | 21 + assets/badges/coverage-methods.svg | 21 + .../MaksIT.Core.Tests.csproj | 2 +- src/MaksIT.Core/MaksIT.Core.csproj | 21 +- src/scripts/BuildUtils.psm1 | 1054 ---------------- src/scripts/Force-AmendTaggedCommit.bat | 6 - src/scripts/Force-AmendTaggedCommit.ps1 | 201 --- src/scripts/Release-NuGetPackage.bat | 7 - src/scripts/Release-NuGetPackage.ps1 | 1118 ----------------- src/scripts/scriptsettings.json | 47 - .../Force-AmendTaggedCommit.bat | 3 + .../Force-AmendTaggedCommit.ps1 | 220 ++++ .../scriptsettings.json | 18 + .../Generate-CoverageBadges.bat | 3 + .../Generate-CoverageBadges.ps1 | 232 ++++ .../scriptsettings.json | 44 + utils/GitTools.psm1 | 265 ++++ utils/Logging.psm1 | 67 + .../Release-NuGetPackage.bat | 3 + .../Release-NuGetPackage.ps1 | 766 +++++++++++ .../Release-NuGetPackage/scriptsettings.json | 67 + utils/ScriptConfig.psm1 | 32 + utils/TestRunner.psm1 | 199 +++ 27 files changed, 2020 insertions(+), 2464 deletions(-) create mode 100644 assets/badges/coverage-branches.svg create mode 100644 assets/badges/coverage-lines.svg create mode 100644 assets/badges/coverage-methods.svg delete mode 100644 src/scripts/BuildUtils.psm1 delete mode 100644 src/scripts/Force-AmendTaggedCommit.bat delete mode 100644 src/scripts/Force-AmendTaggedCommit.ps1 delete mode 100644 src/scripts/Release-NuGetPackage.bat delete mode 100644 src/scripts/Release-NuGetPackage.ps1 delete mode 100644 src/scripts/scriptsettings.json create mode 100644 utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat create mode 100644 utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 create mode 100644 utils/Force-AmendTaggedCommit/scriptsettings.json create mode 100644 utils/Generate-CoverageBadges/Generate-CoverageBadges.bat create mode 100644 utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 create mode 100644 utils/Generate-CoverageBadges/scriptsettings.json create mode 100644 utils/GitTools.psm1 create mode 100644 utils/Logging.psm1 create mode 100644 utils/Release-NuGetPackage/Release-NuGetPackage.bat create mode 100644 utils/Release-NuGetPackage/Release-NuGetPackage.ps1 create mode 100644 utils/Release-NuGetPackage/scriptsettings.json create mode 100644 utils/ScriptConfig.psm1 create mode 100644 utils/TestRunner.psm1 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 @" + + $label`: $value + + + + + + + + + + + + + + + $label + + $value + + +"@ +} + +#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