diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..250b706 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/MaksIT.Dapr.Tests/bin/Debug/net10.0/MaksIT.Dapr.Tests.dll", + "args": [], + "cwd": "${workspaceFolder}/src/MaksIT.Dapr.Tests", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..70df38c --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5105b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +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). + +## v2.0.0 - 2026-02-22 + +### Added +- Dedicated test project (`MaksIT.Dapr.Tests`) with coverage for publisher and state-store service behavior. +- Repository-level utility modules and scripts under `utils/` for release automation, coverage badge generation, and tagged-commit maintenance. + +### Changed +- Upgraded target framework to `.NET 10` (`net10.0`). +- Updated core dependencies to Dapr `1.16.1`, `MaksIT.Core` `1.6.4`, and `MaksIT.Results` `2.0.0`. +- Migrated solution definition from `MaksIT.Dapr.sln` to `MaksIT.Dapr.slnx`, including test project wiring. +- NuGet packaging now includes `CHANGELOG.md` and coverage badge assets. + +### Removed +- Legacy root-level release scripts (`Release-NuGetPackage.*`) in favor of the `utils/Release-NuGetPackage/` flow. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c846487 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ +# Contributing to MaksIT.Dapr + +Thank you for your interest in contributing to `MaksIT.Dapr`. + +## Getting Started + +1. Fork the repository. +2. Clone your fork locally. +3. Create a feature branch. +4. Implement and test your changes. +5. Submit a pull request to `main`. + +## Development Setup + +### Prerequisites + +- .NET 8 SDK or later +- Git +- PowerShell 7+ (recommended for utility scripts) + +### Build + +```bash +cd src +dotnet build MaksIT.Dapr.slnx +``` + +### Test + +```bash +cd src +dotnet test MaksIT.Dapr.Tests +``` + +## Commit Message Format + +Use: + +```text +(type): description +``` + +### Commit Types + +| Type | Description | +|------|-------------| +| `(feature):` | New feature or enhancement | +| `(bugfix):` | Bug fix | +| `(refactor):` | Refactoring without behavior change | +| `(chore):` | Maintenance tasks (dependencies, tooling, docs) | + +### Guidelines + +- Use lowercase in the description. +- Keep it concise and specific. +- Do not end with a period. + +## Pull Request Checklist + +1. Ensure build and tests pass. +2. Update `README.md` if behavior or usage changed. +3. Update `CHANGELOG.md` under the target version. +4. Keep changes scoped and explain rationale in the PR description. + +## Versioning + +This project follows [Semantic Versioning](https://semver.org/): + +- **MAJOR**: breaking API changes +- **MINOR**: backward-compatible features +- **PATCH**: backward-compatible fixes + +## Utility Scripts + +Scripts are located under `utils/`. + +### Generate Coverage Badges + +Runs tests with coverage and generates SVG badges in `assets/badges/`. + +```powershell +.\utils\Generate-CoverageBadges\Generate-CoverageBadges.ps1 +``` + +Configuration: `utils/Generate-CoverageBadges/scriptsettings.json` + +### Release NuGet Package + +Builds, tests, packs, and publishes to NuGet and GitHub release flows. + +```powershell +.\utils\Release-NuGetPackage\Release-NuGetPackage.ps1 +``` + +Prerequisites: + +- Docker Desktop (for Linux test validation) +- GitHub CLI (`gh`) +- environment variable `NUGET_MAKS_IT` +- environment variable `GITHUB_MAKS_IT_COM` + +Configuration: `utils/Release-NuGetPackage/scriptsettings.json` + +### Force Amend Tagged Commit + +Amends the latest tagged commit and force-pushes updated branch and tag. + +```powershell +.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 +.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun +``` + +Warning: this rewrites git history. + +## License + +By contributing, you agree that your contributions are licensed under the terms in `LICENSE.md`. diff --git a/LICENSE.md b/LICENSE.md index edbe7fe..915cfc6 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 - 2025 Maksym Sadovnychyy (MAKS-IT) +Copyright (c) 2024 - 2026 Maksym Sadovnychyy (MAKS-IT) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9fb3758..973edc9 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,27 @@ # MaksIT.Dapr +![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) + This repository hosts the `maksit-dapr` project, which utilizes [Dapr](https://dapr.io/) (Distributed Application Runtime) to facilitate building and managing microservices with ease. The project focuses on implementing a robust, scalable solution leveraging Dapr's building blocks and abstractions. ## Table of Contents -- [Overview](#overview) -- [Features](#features) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) +- [MaksIT.Dapr](#maksitdapr) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [Features](#features) + - [Getting Started](#getting-started) - [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) - - [Running the Project](#running-the-project) - - [Environment Variables](#environment-variables) -- [Testing](#testing) -- [Deployment](#deployment) -- [Contributing](#contributing) -- [License](#license) + - [Usage](#usage) + - [Registering Dapr Services](#registering-dapr-services) + - [Injecting and Using Dapr Services](#injecting-and-using-dapr-services) + - [Contributing](#contributing) + - [Contact](#contact) + - [License](#license) ## Overview -`maksit-dapr` serves as a foundational project to explore and implement Dapr-based microservices, demonstrating the integration of Dapr’s pub-sub, bindings, state management, and other building blocks in a distributed system environment. +`maksit-dapr` serves as a foundational project to explore and implement Dapr-based microservices, demonstrating the integration of Dapr�s pub-sub, bindings, state management, and other building blocks in a distributed system environment. ## Features @@ -176,32 +177,4 @@ If you have any questions or need further assistance, feel free to reach out: ## License -This project is licensed under the MIT License. See the full license text below. - ---- - -### MIT License - -``` -MIT License - -Copyright (c) 2024 Maksym Sadovnychyy (MAKS-IT) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` +See `LICENSE.md`. \ No newline at end of file diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg new file mode 100644 index 0000000..468bb33 --- /dev/null +++ b/assets/badges/coverage-branches.svg @@ -0,0 +1,21 @@ + + Branch Coverage: 50% + + + + + + + + + + + + + + + Branch Coverage + + 50% + + diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg new file mode 100644 index 0000000..cfc17a2 --- /dev/null +++ b/assets/badges/coverage-lines.svg @@ -0,0 +1,21 @@ + + Line Coverage: 62.1% + + + + + + + + + + + + + + + Line Coverage + + 62.1% + + diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg new file mode 100644 index 0000000..ecd0309 --- /dev/null +++ b/assets/badges/coverage-methods.svg @@ -0,0 +1,21 @@ + + Method Coverage: 60% + + + + + + + + + + + + + + + Method Coverage + + 60% + + diff --git a/src/MaksIT.Dapr.Tests/DaprPublisherServiceTests.cs b/src/MaksIT.Dapr.Tests/DaprPublisherServiceTests.cs new file mode 100644 index 0000000..cfbd59b --- /dev/null +++ b/src/MaksIT.Dapr.Tests/DaprPublisherServiceTests.cs @@ -0,0 +1,50 @@ +using Dapr.Client; +using MaksIT.Dapr.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace MaksIT.Dapr.Tests; + +public class DaprPublisherServiceTests { + [Fact] + public async Task PublishEventAsync_ReturnsOk_WhenPublishSucceeds() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.PublishEventAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var service = new DaprPublisherService( + Mock.Of>(), + clientMock.Object); + object payload = new { Name = "payload" }; + + var result = await service.PublishEventAsync("pubsub", "topic", payload); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task PublishEventAsync_ReturnsInternalServerError_WhenPublishFails() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.PublishEventAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("publish failed")); + + var service = new DaprPublisherService( + Mock.Of>(), + clientMock.Object); + object payload = new { Name = "payload" }; + + var result = await service.PublishEventAsync("pubsub", "topic", payload); + + Assert.False(result.IsSuccess); + } +} diff --git a/src/MaksIT.Dapr.Tests/DaprStateStoreServiceTests.cs b/src/MaksIT.Dapr.Tests/DaprStateStoreServiceTests.cs new file mode 100644 index 0000000..6ad6e6f --- /dev/null +++ b/src/MaksIT.Dapr.Tests/DaprStateStoreServiceTests.cs @@ -0,0 +1,95 @@ +using Dapr.Client; +using MaksIT.Dapr.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace MaksIT.Dapr.Tests; + +public class DaprStateStoreServiceTests { + [Fact] + public async Task SetStateAsync_ReturnsOk_WhenSaveSucceeds() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.SaveStateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var service = new DaprStateStoreService( + Mock.Of>(), + clientMock.Object); + + var result = await service.SetStateAsync("store", "key", "value"); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task GetStateAsync_ReturnsOk_WhenStateExists() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.GetStateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync("value"); + + var service = new DaprStateStoreService( + Mock.Of>(), + clientMock.Object); + + var result = await service.GetStateAsync("store", "key"); + + Assert.True(result.IsSuccess); + Assert.Equal("value", result.Value); + } + + [Fact] + public async Task GetStateAsync_ReturnsNotFound_WhenStateIsNull() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.GetStateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((string?)null); + + var service = new DaprStateStoreService( + Mock.Of>(), + clientMock.Object); + + var result = await service.GetStateAsync("store", "key"); + + Assert.False(result.IsSuccess); + Assert.Null(result.Value); + } + + [Fact] + public async Task DeleteStateAsync_ReturnsInternalServerError_WhenDeleteFails() { + var clientMock = new Mock(); + clientMock + .Setup(x => x.DeleteStateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("delete failed")); + + var service = new DaprStateStoreService( + Mock.Of>(), + clientMock.Object); + + var result = await service.DeleteStateAsync("store", "key"); + + Assert.False(result.IsSuccess); + } +} diff --git a/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj new file mode 100644 index 0000000..3ee9796 --- /dev/null +++ b/src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/src/MaksIT.Dapr.sln b/src/MaksIT.Dapr.sln deleted file mode 100644 index 9adcd66..0000000 --- a/src/MaksIT.Dapr.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.11.35327.3 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Dapr", "MaksIT.Dapr\MaksIT.Dapr.csproj", "{D6A8FD32-11E6-422E-9C33-B2D302B87562}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {D6A8FD32-11E6-422E-9C33-B2D302B87562}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6A8FD32-11E6-422E-9C33-B2D302B87562}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6A8FD32-11E6-422E-9C33-B2D302B87562}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6A8FD32-11E6-422E-9C33-B2D302B87562}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {D4625BDB-1AC6-42DA-9D7A-537D368EFE01} - EndGlobalSection -EndGlobal diff --git a/src/MaksIT.Dapr.slnx b/src/MaksIT.Dapr.slnx new file mode 100644 index 0000000..0bbad09 --- /dev/null +++ b/src/MaksIT.Dapr.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/MaksIT.Dapr/MaksIT.Dapr.csproj b/src/MaksIT.Dapr/MaksIT.Dapr.csproj index 801ef65..2214fb0 100644 --- a/src/MaksIT.Dapr/MaksIT.Dapr.csproj +++ b/src/MaksIT.Dapr/MaksIT.Dapr.csproj @@ -1,13 +1,13 @@  - net8.0 + net10.0 enable enable MaksIT.Dapr - 1.0.8 + 2.0.0 Maksym Sadovnychyy MAKS-IT MaksIT.Dapr @@ -21,16 +21,21 @@ - - + + + + + - - - - - + + + + + + PreserveNewest + diff --git a/src/Release-NuGetPackage.bat b/src/Release-NuGetPackage.bat deleted file mode 100644 index 0fe5364..0000000 --- a/src/Release-NuGetPackage.bat +++ /dev/null @@ -1,9 +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" - -pause \ No newline at end of file diff --git a/src/Release-NuGetPackage.ps1 b/src/Release-NuGetPackage.ps1 deleted file mode 100644 index 562c5f8..0000000 --- a/src/Release-NuGetPackage.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -# Retrieve the API key from the environment variable -$apiKey = $env:NUGET_MAKS_IT -if (-not $apiKey) { - Write-Host "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -} - -# NuGet source -$nugetSource = "https://api.nuget.org/v3/index.json" - -# Define paths -$solutionDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$projectDir = "$solutionDir\MaksIT.Dapr" -$outputDir = "$projectDir\bin\Release" - -# Clean previous builds -Write-Host "Cleaning previous builds..." -dotnet clean $projectDir -c Release - -# Build the project -Write-Host "Building the project..." -dotnet build $projectDir -c Release - -# Pack the NuGet package -Write-Host "Packing the project..." -dotnet pack $projectDir -c Release --no-build - -# Look for the .nupkg file -$packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - -if ($packageFile) { - Write-Host "Package created successfully: $($packageFile.FullName)" - - # Push the package to NuGet - Write-Host "Pushing the package to NuGet..." - dotnet nuget push $packageFile.FullName -k $apiKey -s $nugetSource --skip-duplicate - - if ($LASTEXITCODE -eq 0) { - Write-Host "Package pushed successfully." - } else { - Write-Host "Failed to push the package." - } -} else { - Write-Host "Package creation failed. No .nupkg file found." - exit 1 -} diff --git a/src/Release-NuGetPackage.sh b/src/Release-NuGetPackage.sh deleted file mode 100644 index e0a2595..0000000 --- a/src/Release-NuGetPackage.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh - -# Retrieve the API key from the environment variable -apiKey=$NUGET_MAKS_IT -if [ -z "$apiKey" ]; then - echo "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -fi - -# NuGet source -nugetSource="https://api.nuget.org/v3/index.json" - -# Define paths -scriptDir=$(dirname "$0") -solutionDir=$(realpath "$scriptDir") -projectDir="$solutionDir/MaksIT.Dapr" -outputDir="$projectDir/bin/Release" - -# Clean previous builds -echo "Cleaning previous builds..." -dotnet clean "$projectDir" -c Release - -# Build the project -echo "Building the project..." -dotnet build "$projectDir" -c Release - -# Pack the NuGet package -echo "Packing the project..." -dotnet pack "$projectDir" -c Release --no-build - -# Look for the .nupkg file -packageFile=$(find "$outputDir" -name "*.nupkg" -print0 | xargs -0 ls -t | head -n 1) - -if [ -n "$packageFile" ]; then - echo "Package created successfully: $packageFile" - - # Push the package to NuGet - echo "Pushing the package to NuGet..." - dotnet nuget push "$packageFile" -k "$apiKey" -s "$nugetSource" --skip-duplicate - - if [ $? -eq 0 ]; then - echo "Package pushed successfully." - else - echo "Failed to push the package." - fi -else - echo "Package creation failed. No .nupkg file found." - exit 1 -fi 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..bca2946 --- /dev/null +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -0,0 +1,246 @@ +<# +.SYNOPSIS + Amends the latest commit, recreates its associated tag, and force pushes both to remote. + +.DESCRIPTION + This script performs the following operations: + 1. Gets the last commit and verifies it has an associated tag + 2. Stages all pending changes + 3. Amends the latest commit (keeps existing message) + 4. Deletes and recreates the tag on the amended commit + 5. Force pushes the branch and tag to remote + + All configuration is in scriptsettings.json. + +.PARAMETER DryRun + If specified, shows what would be done without making changes. + +.EXAMPLE + .\Force-AmendTaggedCommit.ps1 + +.EXAMPLE + .\Force-AmendTaggedCommit.ps1 -DryRun + +.NOTES + CONFIGURATION (scriptsettings.json): + - git.remote: Remote name to push to (default: "origin") + - git.confirmBeforeAmend: Prompt before amending (default: true) + - git.confirmWhenNoChanges: Prompt if no pending changes (default: true) +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [switch]$DryRun +) + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$utilsDir = Split-Path $scriptDir -Parent + +#region Import Modules + +# Import shared ScriptConfig module (settings loading + dependency checks) +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +# Import shared GitTools module (git operations used by this script) +$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Helpers + +function Select-PreferredHeadTag { + param( + [Parameter(Mandatory = $true)] + [string[]]$Tags + ) + + # Pick the latest tag on HEAD by git's own ordering (no tag-name parsing assumptions). + $ordered = (& git tag --points-at HEAD --sort=-creatordate 2>$null) + if ($LASTEXITCODE -eq 0 -and $ordered) { + $orderedTags = @($ordered | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + if ($orderedTags.Count -gt 0) { + return $orderedTags[0] + } + } + + # Fallback: keep script functional even if sorting is unavailable. + return $Tags[0] +} + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#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, choose the latest one on HEAD by git ordering. +if ($tags.Count -gt 1) { + Write-Log -Level "WARN" -Message "Multiple tags found on HEAD: $($tags -join ', ')" +} +$TagName = Select-PreferredHeadTag -Tags $tags +Write-Log -Level "OK" -Message "Found tag: $TagName" + +# 4. Inspect pending changes before amend +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..39450b7 --- /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.Dapr.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..dbacad7 --- /dev/null +++ b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1 @@ -0,0 +1,751 @@ +<# +.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 NuGet package assets + - Shows timing summary for all steps + +.REQUIREMENTS + Environment Variables: + - NUGET_MAKS_IT : NuGet.org API key for publishing packages + - GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope) + + Tools (Required): + - dotnet CLI : For building, testing, and packing + - git : For version control operations + - gh (GitHub CLI) : For creating GitHub releases + - docker : For cross-platform Linux testing + +.WORKFLOW + 1. VALIDATION PHASE + - Check required environment variables (NuGet key, GitHub token) + - Check required tools are installed (dotnet, git, gh, docker) + - Verify no uncommitted changes in working directory + - Authenticate GitHub CLI + + 2. VERSION & RELEASE CHECK PHASE (Idempotent) + - Read latest version from CHANGELOG.md + - Find commit with matching version tag + - Validate tag is on configured release branch (from scriptsettings.json) + - Check if already released on NuGet.org (mark for skip if yes) + - Check if GitHub release exists (mark for skip if yes) + - Read target framework from MaksIT.Core.csproj + - Extract release notes from CHANGELOG.md for current version + + 3. SECURITY SCAN + - Check for vulnerable packages (dotnet list package --vulnerable) + - Fail or warn based on $failOnVulnerabilities setting + + 4. BUILD & TEST PHASE + - Clean previous builds (delete bin/obj folders) + - Restore NuGet packages + - Windows: Build main project -> Build test project -> Run tests with coverage + - Analyze code coverage (fail if below threshold when configured) + - Linux (Docker): Build main project -> Build test project -> Run tests (TRX report) + - Rebuild for Windows (Docker may overwrite bin/obj) + - Create NuGet package (.nupkg) and symbols (.snupkg) + - All steps are timed for performance tracking + + 5. CONFIRMATION PHASE + - Display release summary + - Prompt user for confirmation before proceeding + + 6. NUGET RELEASE PHASE (Idempotent) + - Skip if version already exists on NuGet.org + - Otherwise, push package to NuGet.org + + 7. GITHUB RELEASE PHASE (Idempotent) + - Skip if release already exists + - Push tag to remote if not already there + - Create GitHub release with: + * Release notes from CHANGELOG.md + * .nupkg and .snupkg as downloadable assets + + 8. COMPLETION PHASE + - Display timing summary for all steps + - Display test results summary + - Display success summary with links + - Open NuGet and GitHub release pages in browser + - TODO: Email notification (template provided) + - TODO: Package signing (template provided) + +.USAGE + Before running: + 1. Ensure Docker Desktop is running (for Linux tests) + 2. Update version in MaksIT.Core.csproj + 3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md + 4. Review and commit all changes + 5. Create version tag: git tag v1.x.x + 6. Run: .\Release-NuGetPackage.ps1 + + Note: The script finds the commit with the tag matching CHANGELOG.md version. + You can run it from any branch/commit - it releases the tagged commit. + + Re-run release (idempotent - skips NuGet/GitHub if already released): + .\Release-NuGetPackage.ps1 + + Generate changelog and update LICENSE year: + .\Generate-Changelog.ps1 + +.CONFIGURATION + All settings are stored in scriptsettings.json: + - qualityGates: Coverage threshold, vulnerability checks + - packageSigning: Code signing certificate configuration + - emailNotification: SMTP settings for release notifications + +.NOTES + Author: Maksym Sadovnychyy (MAKS-IT) + Repository: https://github.com/MAKS-IT-COM/maksit-core +#> + +# No parameters - behavior is controlled by current branch (configured in scriptsettings.json): +# - dev branch -> Local build only (no tag required, uncommitted changes allowed) +# - release branch -> Full release to GitHub (tag required, clean working directory) + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +#region Import Modules + +# Import TestRunner module +$utilsDir = Split-Path $scriptDir -Parent + +$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1" +if (-not (Test-Path $testRunnerModulePath)) { + Write-Error "TestRunner module not found at: $testRunnerModulePath" + exit 1 +} + +Import-Module $testRunnerModulePath -Force + +# Import ScriptConfig module +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force + +# Import Logging module +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $loggingModulePath -Force + + +# Import GitTools module +$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} + +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Load Settings +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Environment Helpers + +# Resolve environment variable in Process -> User -> Machine order. +function Get-EnvVarValue { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $processValue = [System.Environment]::GetEnvironmentVariable($Name, [System.EnvironmentVariableTarget]::Process) + if (-not [string]::IsNullOrWhiteSpace($processValue)) { + return $processValue + } + + $userValue = [System.Environment]::GetEnvironmentVariable($Name, [System.EnvironmentVariableTarget]::User) + if (-not [string]::IsNullOrWhiteSpace($userValue)) { + return $userValue + } + + $machineValue = [System.Environment]::GetEnvironmentVariable($Name, [System.EnvironmentVariableTarget]::Machine) + if (-not [string]::IsNullOrWhiteSpace($machineValue)) { + return $machineValue + } + + return $null +} + +#endregion + +#region Configuration + +# GitHub configuration +$githubReleseEnabled = $settings.github.enabled +$githubTokenEnvVar = $settings.github.githubToken +$githubToken = Get-EnvVarValue -Name $githubTokenEnvVar + +# NuGet configuration +$nugetReleseEnabled = $settings.nuget.enabled +$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey +$nugetApiKey = Get-EnvVarValue -Name $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)) +$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 pattern +$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: check for uncommitted changes +function Assert-WorkingTreeClean { + param( + [Parameter(Mandatory = $true)] + [bool]$IsReleaseBranch + ) + + $gitStatus = Get-GitStatusShort + if ($gitStatus) { + if ($IsReleaseBranch) { + Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." + Write-Log -Level "WARN" -Message "Uncommitted files:" + $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + exit 1 + } + else { + Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)." + } + } + else { + Write-Log -Level "OK" -Message " Working directory is clean." + } +} + +# Helper: read versions from csproj files +function Get-CsprojVersions { + param( + [Parameter(Mandatory = $true)] + [string[]]$CsprojPaths + ) + + Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..." + $projectVersions = @{} + + foreach ($projPath in $CsprojPaths) { + if (-not (Test-Path $projPath -PathType Leaf)) { + Write-Error "Csproj file not found at: $projPath" + exit 1 + } + + if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") { + Write-Error "Configured path is not a .csproj file: $projPath" + exit 1 + } + + [xml]$csproj = Get-Content $projPath + $version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version" + + if (-not $version) { + Write-Error "Version not found in $projPath" + exit 1 + } + + $projectVersions[$projPath] = $version + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version" + } + + return $projectVersions +} + +#endregion + +#region Validate CLI Dependencies + +Assert-Command dotnet +Assert-Command git +Assert-Command docker +# gh command check deferred until after branch detection (only needed on release branch) + +#endregion + +#region Main + +Write-Log -Level "STEP" -Message "==================================================" +Write-Log -Level "STEP" -Message "RELEASE BUILD" +Write-Log -Level "STEP" -Message "==================================================" + +#region Preflight + +$isDevBranch = $false +$isReleaseBranch = $false + +# 1. Detect current branch and determine release mode +$currentBranch = Get-CurrentBranch + +$isDevBranch = $currentBranch -eq $devBranch +$isReleaseBranch = $currentBranch -eq $releaseBranch + +if (-not $isDevBranch -and -not $isReleaseBranch) { + Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch" + exit 1 +} + +# 2. Check for uncommitted changes (required on release branch, allowed on dev) +Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch + +# 3. Get version from csproj (source of truth) +$projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths + +# Use the first project's version as the release version +$version = $projectVersions[$csprojPaths[0]] + +# 4. Handle tag based on branch +if ($isReleaseBranch) { + # Release branch: tag is required and must match version + $tag = Get-CurrentCommitTag -Version $version + + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." + exit 1 + } + + $tagVersion = $Matches[1] + + if ($tagVersion -ne $version) { + Write-Error "Tag version ($tagVersion) does not match csproj version ($version)." + Write-Log -Level "WARN" -Message " Either update the tag or the csproj version." + exit 1 + } + + Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)" +} +else { + # Dev branch: no tag required, use version from csproj + $tag = "v$version" + Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)." +} + +# 5. Verify CHANGELOG.md has matching version entry +Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..." +if (-not (Test-Path $changelogPath)) { + Write-Error "CHANGELOG.md not found at: $changelogPath" + exit 1 +} + +$changelog = Get-Content $changelogPath -Raw + +if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') { + Write-Error "No version entry found in CHANGELOG.md" + exit 1 +} + +$changelogVersion = $Matches[1] + +if ($changelogVersion -ne $version) { + Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)." + Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version." + exit 1 +} + +Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion" + + + +Write-Log -Level "OK" -Message "All pre-flight checks passed!" + +#endregion + +#region Test + +Write-Log -Level "STEP" -Message "Running tests..." + +# Run tests using TestRunner module +$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent + +if (-not $testResult.Success) { + Write-Error "Tests failed. Release aborted." + Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)" + exit 1 +} + +Write-Log -Level "OK" -Message " All tests passed!" +Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" +Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%" +Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%" + +#endregion + +#region Build And Publish + +# 7. Prepare release directory +if (!(Test-Path $releaseDir)) { + New-Item -ItemType Directory -Path $releaseDir | Out-Null +} + + +# 8. Pack NuGet package and resolve produced .nupkg/.snupkg files +$packageProjectPath = $csprojPaths[0] +Write-Log -Level "STEP" -Message "Packing NuGet package..." +dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo ` + -p:IncludeSymbols=true ` + -p:SymbolPackageFormat=snupkg +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)" + +# Find the symbols package if available +$symbolsPackageFile = Get-ChildItem -Path $releaseDir -Filter "*.snupkg" | + Where-Object { $_.Name -like "*$version*.snupkg" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if ($symbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)" +} +else { + Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." +} + +# 9. Create release archive with NuGet package artifacts +Write-Log -Level "STEP" -Message "Creating release archive..." +$resolvedZipNamePattern = if ([string]::IsNullOrWhiteSpace($zipNamePattern)) { "release-{version}.zip" } else { $zipNamePattern } +$zipFileName = $resolvedZipNamePattern -replace '\{version\}', $version +$zipPath = Join-Path $releaseDir $zipFileName + +if (Test-Path $zipPath) { + Remove-Item $zipPath -Force +} + +$archiveArtifacts = @($packageFile.FullName) +if ($symbolsPackageFile) { + $archiveArtifacts += $symbolsPackageFile.FullName +} + +Compress-Archive -Path $archiveArtifacts -DestinationPath $zipPath -CompressionLevel Optimal -Force + +if (-not (Test-Path $zipPath)) { + Write-Error "Failed to create release archive at: $zipPath" + exit 1 +} + +Write-Log -Level "OK" -Message " Release archive ready: $zipPath" + +# 10. 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." + +# 11. Get repository info +$remoteUrl = git config --get remote.origin.url +if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) { + Write-Error "Could not determine git remote origin URL." + exit 1 +} + +if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { + $owner = $matches['owner'] + $repoName = $matches['repo'] + $repo = "$owner/$repoName" +} else { + Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl" + exit 1 +} + +$releaseName = $releaseTitlePattern -replace '\{version\}', $version + +Write-Log -Level "STEP" -Message "Release Summary:" +Write-Log -Level "INFO" -Message " Repository: $repo" +Write-Log -Level "INFO" -Message " Tag: $tag" +Write-Log -Level "INFO" -Message " Title: $releaseName" + +# 12. 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 + } + + $previousGhTokenForAuth = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + $authTest = & gh api user 2>$null + + if ($LASTEXITCODE -ne 0 -or -not $authTest) { + if ($null -ne $previousGhTokenForAuth) { + $env:GH_TOKEN = $previousGhTokenForAuth + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope." + exit 1 + } + + if ($null -ne $previousGhTokenForAuth) { + $env:GH_TOKEN = $previousGhTokenForAuth + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + + Write-Log -Level "OK" -Message " GitHub CLI authenticated." + + # 13. Create or update GitHub release + Write-Log -Level "STEP" -Message "Creating GitHub release..." + + # gh release subcommands do not support custom auth headers. + # Scope GH_TOKEN to this block so commands authenticate with the configured token. + $previousGhToken = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + + try { + # Check if release already exists + $releaseViewArgs = @( + "release", "view", $tag, + "--repo", $repo + ) + & gh @releaseViewArgs 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." + $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") + & gh @releaseDeleteArgs + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to delete existing release $tag." + exit 1 + } + } + + # Create release using the existing tag + # Write release notes to a temp file to avoid shell interpretation issues with special characters + $notesFilePath = Join-Path $releaseDir "release-notes-temp.md" + [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) + + $releaseAssets = @($packageFile.FullName) + if ($symbolsPackageFile) { + $releaseAssets += $symbolsPackageFile.FullName + } + + $createReleaseArgs = @("release", "create", $tag) + $releaseAssets + @( + "--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 $testResultsDir) { + Remove-Item $testResultsDir -Recurse -Force + Write-Log -Level "INFO" -Message " Cleaned up test results directory." +} + +Get-ChildItem -Path $releaseDir -File | + Where-Object { $_.Name -like "*$version*.nupkg" -or $_.Name -like "*$version*.snupkg" } | + Remove-Item -Force -ErrorAction SilentlyContinue +#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..27ad9bc --- /dev/null +++ b/utils/Release-NuGetPackage/scriptsettings.json @@ -0,0 +1,65 @@ +{ + "$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-dapr" + }, + + "nuget": { + "enabled": true, + "nugetApiKey": "NUGET_MAKS_IT", + "source": "https://api.nuget.org/v3/index.json" + }, + + "branches": { + "release": "main", + "dev": "dev" + }, + + "paths": { + "csprojPaths": [ + "..\\..\\src\\MaksIT.Dapr\\MaksIT.Dapr.csproj" + ], + "testResultsDir": "..\\..\\testResults", + "releaseDir": "..\\..\\release", + "changelogPath": "..\\..\\CHANGELOG.md", + "testProject": "..\\..\\src\\MaksIT.Dapr.Tests" + }, + + "release": { + "zipNamePattern": "maksit.dapr-{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.", + "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