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
+  
+
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
+ Branch Coverage
+ 50%
+ 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
+ Line Coverage
+ 62.1%
+ 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
+ Method Coverage
+ 60%
+ 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
+ $label
+ $value
+ $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..fd87cf0
--- /dev/null
+++ b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1
@@ -0,0 +1,719 @@
+<#
+.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 Configuration
+
+# GitHub configuration
+$githubReleseEnabled = $settings.github.enabled
+$githubTokenEnvVar = $settings.github.githubToken
+$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
+
+# NuGet configuration
+$nugetReleseEnabled = $settings.nuget.enabled
+$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey
+$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
+$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" }
+
+# Paths from settings (resolve relative to script directory)
+$csprojPaths = @()
+$rawCsprojPaths = @()
+
+if ($settings.paths.csprojPaths) {
+ if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) {
+ $rawCsprojPaths += $settings.paths.csprojPaths
+ }
+ else {
+ $rawCsprojPaths += $settings.paths.csprojPaths
+ }
+}
+else {
+ Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json."
+ exit 1
+}
+
+foreach ($path in $rawCsprojPaths) {
+ if ([string]::IsNullOrWhiteSpace($path)) {
+ continue
+ }
+
+ $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
+ $csprojPaths += $resolvedPath
+}
+
+if ($csprojPaths.Count -eq 0) {
+ Write-Error "No valid csproj paths configured in scriptsettings.json."
+ exit 1
+}
+
+$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir))
+$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
+ }
+
+ # 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 {
+ # Validate token by checking access to the target repository.
+ # This avoids false negatives from "gh api user" with fine-grained tokens.
+ $authArgs = @(
+ "api", "repos/$repo",
+ "--jq", ".full_name"
+ )
+ $authTest = & gh @authArgs 2>$null
+
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($authTest)) {
+ $authStatus = & gh auth status --hostname github.com 2>&1
+ if ($authStatus) {
+ Write-Log -Level "WARN" -Message " gh auth status output:"
+ $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
+ }
+
+ Write-Error "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' contains a valid token with repository access."
+ exit 1
+ }
+
+ Write-Log -Level "OK" -Message " GitHub token validated for repository: $authTest"
+
+ # 13. 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))
+
+ $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