(feature): retarget MaksIT.Dapr to .NET 10 and release v2.0.0
This commit is contained in:
parent
d07fc13b56
commit
3b4d44598d
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
.vscode/tasks.json
vendored
Normal file
41
.vscode/tasks.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@ -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.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Template for new releases:
|
||||||
|
|
||||||
|
## v1.x.x
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New features
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Changes in existing functionality
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- Soon-to-be removed features
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed features
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fixes
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Security improvements
|
||||||
|
-->
|
||||||
117
CONTRIBUTING.md
Normal file
117
CONTRIBUTING.md
Normal file
@ -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`.
|
||||||
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
57
README.md
57
README.md
@ -1,26 +1,27 @@
|
|||||||
# MaksIT.Dapr
|
# 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.
|
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
|
## Table of Contents
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [MaksIT.Dapr](#maksitdapr)
|
||||||
- [Features](#features)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Getting Started](#getting-started)
|
- [Overview](#overview)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Features](#features)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Configuration](#configuration)
|
- [Usage](#usage)
|
||||||
- [Usage](#usage)
|
- [Registering Dapr Services](#registering-dapr-services)
|
||||||
- [Running the Project](#running-the-project)
|
- [Injecting and Using Dapr Services](#injecting-and-using-dapr-services)
|
||||||
- [Environment Variables](#environment-variables)
|
- [Contributing](#contributing)
|
||||||
- [Testing](#testing)
|
- [Contact](#contact)
|
||||||
- [Deployment](#deployment)
|
- [License](#license)
|
||||||
- [Contributing](#contributing)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
## Overview
|
## 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<EFBFBD>s pub-sub, bindings, state management, and other building blocks in a distributed system environment.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -176,32 +177,4 @@ If you have any questions or need further assistance, feel free to reach out:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License. See the full license text below.
|
See `LICENSE.md`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
```
|
|
||||||
21
assets/badges/coverage-branches.svg
Normal file
21
assets/badges/coverage-branches.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 50%">
|
||||||
|
<title>Branch Coverage: 50%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="147.5" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="107.5" height="20" fill="#555"/>
|
||||||
|
<rect x="107.5" width="40" height="20" fill="#a4a61d"/>
|
||||||
|
<rect width="147.5" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
|
||||||
|
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
|
||||||
|
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">50%</text>
|
||||||
|
<text x="127.5" y="14" fill="#fff">50%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
21
assets/badges/coverage-lines.svg
Normal file
21
assets/badges/coverage-lines.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 62.1%">
|
||||||
|
<title>Line Coverage: 62.1%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="137" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="94.5" height="20" fill="#555"/>
|
||||||
|
<rect x="94.5" width="42.5" height="20" fill="#97ca00"/>
|
||||||
|
<rect width="137" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
|
||||||
|
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
|
||||||
|
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">62.1%</text>
|
||||||
|
<text x="115.75" y="14" fill="#fff">62.1%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
21
assets/badges/coverage-methods.svg
Normal file
21
assets/badges/coverage-methods.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Method Coverage: 60%">
|
||||||
|
<title>Method Coverage: 60%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="147.5" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="107.5" height="20" fill="#555"/>
|
||||||
|
<rect x="107.5" width="40" height="20" fill="#97ca00"/>
|
||||||
|
<rect width="147.5" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
|
||||||
|
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
|
||||||
|
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
|
||||||
|
<text x="127.5" y="14" fill="#fff">60%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
50
src/MaksIT.Dapr.Tests/DaprPublisherServiceTests.cs
Normal file
50
src/MaksIT.Dapr.Tests/DaprPublisherServiceTests.cs
Normal file
@ -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<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.PublishEventAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<object>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new DaprPublisherService(
|
||||||
|
Mock.Of<ILogger<DaprPublisherService>>(),
|
||||||
|
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<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.PublishEventAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<object>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("publish failed"));
|
||||||
|
|
||||||
|
var service = new DaprPublisherService(
|
||||||
|
Mock.Of<ILogger<DaprPublisherService>>(),
|
||||||
|
clientMock.Object);
|
||||||
|
object payload = new { Name = "payload" };
|
||||||
|
|
||||||
|
var result = await service.PublishEventAsync("pubsub", "topic", payload);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/MaksIT.Dapr.Tests/DaprStateStoreServiceTests.cs
Normal file
95
src/MaksIT.Dapr.Tests/DaprStateStoreServiceTests.cs
Normal file
@ -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<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.SaveStateAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<StateOptions>(),
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new DaprStateStoreService(
|
||||||
|
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||||
|
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<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.GetStateAsync<string?>(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<ConsistencyMode?>(),
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("value");
|
||||||
|
|
||||||
|
var service = new DaprStateStoreService(
|
||||||
|
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||||
|
clientMock.Object);
|
||||||
|
|
||||||
|
var result = await service.GetStateAsync<string>("store", "key");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("value", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStateAsync_ReturnsNotFound_WhenStateIsNull() {
|
||||||
|
var clientMock = new Mock<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.GetStateAsync<string?>(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<ConsistencyMode?>(),
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((string?)null);
|
||||||
|
|
||||||
|
var service = new DaprStateStoreService(
|
||||||
|
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||||
|
clientMock.Object);
|
||||||
|
|
||||||
|
var result = await service.GetStateAsync<string>("store", "key");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Null(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteStateAsync_ReturnsInternalServerError_WhenDeleteFails() {
|
||||||
|
var clientMock = new Mock<DaprClient>();
|
||||||
|
clientMock
|
||||||
|
.Setup(x => x.DeleteStateAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<StateOptions>(),
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("delete failed"));
|
||||||
|
|
||||||
|
var service = new DaprStateStoreService(
|
||||||
|
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||||
|
clientMock.Object);
|
||||||
|
|
||||||
|
var result = await service.DeleteStateAsync("store", "key");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj
Normal file
29
src/MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageReference Include="Moq" Version="4.*" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
|
||||||
|
<PackageReference Include="xunit.v3" Version="3.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.Dapr\MaksIT.Dapr.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -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
|
|
||||||
9
src/MaksIT.Dapr.slnx
Normal file
9
src/MaksIT.Dapr.slnx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Solution>
|
||||||
|
<Configurations>
|
||||||
|
<Platform Name="Any CPU" />
|
||||||
|
<Platform Name="x64" />
|
||||||
|
<Platform Name="x86" />
|
||||||
|
</Configurations>
|
||||||
|
<Project Path="MaksIT.Dapr.Tests/MaksIT.Dapr.Tests.csproj" />
|
||||||
|
<Project Path="MaksIT.Dapr/MaksIT.Dapr.csproj" />
|
||||||
|
</Solution>
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Dapr</PackageId>
|
<PackageId>MaksIT.Dapr</PackageId>
|
||||||
<Version>1.0.8</Version>
|
<Version>2.0.0</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Dapr</Product>
|
<Product>MaksIT.Dapr</Product>
|
||||||
@ -21,16 +21,21 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.16.1" />
|
||||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
<PackageReference Include="Dapr.AspNetCore" Version="1.16.1" />
|
||||||
|
<PackageReference Include="Dapr.Workflow" Version="1.16.1" />
|
||||||
|
<PackageReference Include="MaksIT.Core" Version="1.6.4" />
|
||||||
|
<PackageReference Include="MaksIT.Results" Version="2.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.16.0" />
|
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
|
||||||
<PackageReference Include="Dapr.AspNetCore" Version="1.16.0" />
|
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
|
||||||
<PackageReference Include="Dapr.Workflow" Version="1.16.0" />
|
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
|
||||||
<PackageReference Include="MaksIT.Core" Version="1.5.1" />
|
|
||||||
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
|
<None Include="..\..\assets\badges\**\*" Link="assets\badges\%(RecursiveDir)%(Filename)%(Extension)">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||||
|
pause
|
||||||
246
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
246
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
@ -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
|
||||||
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$comment": "Configuration for Force-AmendTaggedCommit.ps1",
|
||||||
|
|
||||||
|
"git": {
|
||||||
|
"remote": "origin",
|
||||||
|
"confirmBeforeAmend": true,
|
||||||
|
"confirmWhenNoChanges": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"_comments": {
|
||||||
|
"git": {
|
||||||
|
"remote": "Remote name used for force-pushing branch and tag",
|
||||||
|
"confirmBeforeAmend": "Ask for confirmation before amend + force-push operations",
|
||||||
|
"confirmWhenNoChanges": "Ask for confirmation when there are no pending changes to amend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||||
|
pause
|
||||||
232
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
232
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
@ -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 @"
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
|
||||||
|
<title>$label`: $value</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="$labelWidth" height="20" fill="#555"/>
|
||||||
|
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
|
||||||
|
<rect width="$totalWidth" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
|
||||||
|
<text x="$labelX" y="14" fill="#fff">$label</text>
|
||||||
|
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
|
||||||
|
<text x="$valueX" y="14" fill="#fff">$value</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Main
|
||||||
|
|
||||||
|
#region Test And Coverage
|
||||||
|
|
||||||
|
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
|
||||||
|
if (-not $coverage.Success) {
|
||||||
|
Write-Error "Tests failed: $($coverage.Error)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log -Level "OK" -Message "Tests passed!"
|
||||||
|
|
||||||
|
$metrics = @{
|
||||||
|
"line" = $coverage.LineRate
|
||||||
|
"branch" = $coverage.BranchRate
|
||||||
|
"method" = $coverage.MethodRate
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Generate Badges
|
||||||
|
|
||||||
|
Write-LogStep -Message "Generating coverage badges..."
|
||||||
|
|
||||||
|
foreach ($badge in $Settings.badges) {
|
||||||
|
$metricValue = $metrics[$badge.metric]
|
||||||
|
$color = Get-BadgeColor $metricValue
|
||||||
|
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
|
||||||
|
$path = Join-Path $BadgesDir $badge.name
|
||||||
|
$svg | Out-File -FilePath $path -Encoding utf8
|
||||||
|
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Summary
|
||||||
|
|
||||||
|
Write-Log -Level "INFO" -Message "Coverage Summary:"
|
||||||
|
Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%"
|
||||||
|
Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%"
|
||||||
|
Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
|
||||||
|
Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir"
|
||||||
|
Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README."
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Optional Html Report
|
||||||
|
|
||||||
|
if ($OpenReport -and $coverage.CoverageFile) {
|
||||||
|
Write-LogStep -Message "Generating HTML report..."
|
||||||
|
Assert-Command reportgenerator
|
||||||
|
|
||||||
|
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
|
||||||
|
$ReportDir = Join-Path $ResultsDir "report"
|
||||||
|
|
||||||
|
$reportGenArgs = @(
|
||||||
|
"-reports:$($coverage.CoverageFile)"
|
||||||
|
"-targetdir:$ReportDir"
|
||||||
|
"-reporttypes:Html"
|
||||||
|
)
|
||||||
|
& reportgenerator @reportGenArgs
|
||||||
|
|
||||||
|
$IndexFile = Join-Path $ReportDir "index.html"
|
||||||
|
if (Test-Path $IndexFile) {
|
||||||
|
Start-Process $IndexFile
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing."
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#endregion
|
||||||
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Generate Coverage Badges Script Settings",
|
||||||
|
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
||||||
|
"openReport": false,
|
||||||
|
"paths": {
|
||||||
|
"testProject": "..\\..\\src\\MaksIT.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."
|
||||||
|
}
|
||||||
|
}
|
||||||
265
utils/GitTools.psm1
Normal file
265
utils/GitTools.psm1
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
#
|
||||||
|
# Shared Git helpers for utility scripts.
|
||||||
|
#
|
||||||
|
|
||||||
|
function Import-LoggingModuleInternal {
|
||||||
|
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulePath = Join-Path $PSScriptRoot "Logging.psm1"
|
||||||
|
if (Test-Path $modulePath) {
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-GitToolsLogInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Message,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||||
|
[string]$Level = "INFO"
|
||||||
|
)
|
||||||
|
|
||||||
|
Import-LoggingModuleInternal
|
||||||
|
|
||||||
|
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Log -Level $Level -Message $Message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host $Message -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Internal:
|
||||||
|
# Purpose:
|
||||||
|
# - Execute a git command and enforce fail-fast error handling.
|
||||||
|
function Invoke-GitInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string[]]$Arguments,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$CaptureOutput,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$ErrorMessage = "Git command failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($CaptureOutput) {
|
||||||
|
$output = & git @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-Error "$ErrorMessage (exit code: $exitCode)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $output) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($output -join "`n").Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
& git @Arguments
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-Error "$ErrorMessage (exit code: $exitCode)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Resolve and print the current branch name.
|
||||||
|
function Get-CurrentBranch {
|
||||||
|
Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..."
|
||||||
|
|
||||||
|
$branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch"
|
||||||
|
Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch"
|
||||||
|
return $branch
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Return `git status --short` output for pending-change checks.
|
||||||
|
function Get-GitStatusShort {
|
||||||
|
return Invoke-GitInternal -Arguments @("status", "--short") -CaptureOutput -ErrorMessage "Failed to get git status"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Get exact tag name attached to HEAD (release flow).
|
||||||
|
function Get-CurrentCommitTag {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-GitToolsLogInternal -Level "STEP" -Message "Checking for tag on current commit..."
|
||||||
|
$tag = Invoke-GitInternal -Arguments @("describe", "--tags", "--exact-match", "HEAD") -CaptureOutput -ErrorMessage "No tag found on current commit. Create a tag: git tag v$Version"
|
||||||
|
return $tag
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Get all tag names pointing at HEAD.
|
||||||
|
function Get-HeadTags {
|
||||||
|
$tagsRaw = Invoke-GitInternal -Arguments @("tag", "--points-at", "HEAD") -CaptureOutput -ErrorMessage "Failed to list tags on HEAD"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($tagsRaw)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($tagsRaw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Check whether a given tag exists on the remote.
|
||||||
|
function Test-RemoteTagExists {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Tag,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$Remote = "origin"
|
||||||
|
)
|
||||||
|
|
||||||
|
$remoteTag = Invoke-GitInternal -Arguments @("ls-remote", "--tags", $Remote, $Tag) -CaptureOutput -ErrorMessage "Failed to check remote tag existence"
|
||||||
|
return [bool]$remoteTag
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Push tag to remote (optionally with `--force`).
|
||||||
|
function Push-TagToRemote {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Tag,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$Remote = "origin",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$pushArgs = @("push")
|
||||||
|
if ($Force) {
|
||||||
|
$pushArgs += "--force"
|
||||||
|
}
|
||||||
|
$pushArgs += @($Remote, $Tag)
|
||||||
|
|
||||||
|
Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push tag $Tag to remote $Remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Push branch to remote (optionally with `--force`).
|
||||||
|
function Push-BranchToRemote {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Branch,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$Remote = "origin",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$pushArgs = @("push")
|
||||||
|
if ($Force) {
|
||||||
|
$pushArgs += "--force"
|
||||||
|
}
|
||||||
|
$pushArgs += @($Remote, $Branch)
|
||||||
|
|
||||||
|
Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push branch $Branch to remote $Remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Get HEAD commit hash.
|
||||||
|
function Get-HeadCommitHash {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$Short
|
||||||
|
)
|
||||||
|
|
||||||
|
$format = if ($Short) { "--format=%h" } else { "--format=%H" }
|
||||||
|
return Invoke-GitInternal -Arguments @("log", "-1", $format) -CaptureOutput -ErrorMessage "Failed to get HEAD commit hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Get HEAD commit subject line.
|
||||||
|
function Get-HeadCommitMessage {
|
||||||
|
return Invoke-GitInternal -Arguments @("log", "-1", "--format=%s") -CaptureOutput -ErrorMessage "Failed to get HEAD commit message"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Stage all changes (tracked, untracked, deletions).
|
||||||
|
function Add-AllChanges {
|
||||||
|
Invoke-GitInternal -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Amend HEAD commit and keep existing commit message.
|
||||||
|
function Update-HeadCommitNoEdit {
|
||||||
|
Invoke-GitInternal -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Delete local tag.
|
||||||
|
function Remove-LocalTag {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Tag
|
||||||
|
)
|
||||||
|
|
||||||
|
Invoke-GitInternal -Arguments @("tag", "-d", $Tag) -ErrorMessage "Failed to delete local tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Create local tag.
|
||||||
|
function New-LocalTag {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Tag
|
||||||
|
)
|
||||||
|
|
||||||
|
Invoke-GitInternal -Arguments @("tag", $Tag) -ErrorMessage "Failed to create tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by:
|
||||||
|
# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
|
||||||
|
# Purpose:
|
||||||
|
# - Get HEAD one-line commit info.
|
||||||
|
function Get-HeadCommitOneLine {
|
||||||
|
return Invoke-GitInternal -Arguments @("log", "-1", "--oneline") -CaptureOutput -ErrorMessage "Failed to read final commit state"
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Get-CurrentBranch, Get-GitStatusShort, Get-CurrentCommitTag, Get-HeadTags, Test-RemoteTagExists, Push-TagToRemote, Push-BranchToRemote, Get-HeadCommitHash, Get-HeadCommitMessage, Add-AllChanges, Update-HeadCommitNoEdit, Remove-LocalTag, New-LocalTag, Get-HeadCommitOneLine
|
||||||
67
utils/Logging.psm1
Normal file
67
utils/Logging.psm1
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
function Get-LogTimestampInternal {
|
||||||
|
return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogColorInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Level
|
||||||
|
)
|
||||||
|
|
||||||
|
switch ($Level.ToUpperInvariant()) {
|
||||||
|
"OK" { return "Green" }
|
||||||
|
"INFO" { return "Gray" }
|
||||||
|
"WARN" { return "Yellow" }
|
||||||
|
"ERROR" { return "Red" }
|
||||||
|
"STEP" { return "Cyan" }
|
||||||
|
"DEBUG" { return "DarkGray" }
|
||||||
|
default { return "White" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Log {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Message,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||||
|
[string]$Level = "INFO",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$NoTimestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
$levelToken = "[$($Level.ToUpperInvariant())]"
|
||||||
|
$padding = " " * [Math]::Max(1, (10 - $levelToken.Length))
|
||||||
|
$prefix = if ($NoTimestamp) { "" } else { "[$(Get-LogTimestampInternal)] " }
|
||||||
|
$line = "$prefix$levelToken$padding$Message"
|
||||||
|
|
||||||
|
Write-Host $line -ForegroundColor (Get-LogColorInternal -Level $Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-LogStep {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Log -Level "STEP" -Message $Message
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-LogStepResult {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("OK", "FAIL")]
|
||||||
|
[string]$Status,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
|
||||||
|
$level = if ($Status -eq "FAIL") { "ERROR" } else { "OK" }
|
||||||
|
$text = if ([string]::IsNullOrWhiteSpace($Message)) { $Status } else { $Message }
|
||||||
|
Write-Log -Level $level -Message $text
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Write-Log, Write-LogStep, Write-LogStepResult
|
||||||
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||||
|
pause
|
||||||
719
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
719
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
@ -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 "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||||
|
$owner = $matches['owner']
|
||||||
|
$repoName = $matches['repo']
|
||||||
|
$repo = "$owner/$repoName"
|
||||||
|
} else {
|
||||||
|
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||||
|
|
||||||
|
Write-Log -Level "STEP" -Message "Release Summary:"
|
||||||
|
Write-Log -Level "INFO" -Message " Repository: $repo"
|
||||||
|
Write-Log -Level "INFO" -Message " Tag: $tag"
|
||||||
|
Write-Log -Level "INFO" -Message " Title: $releaseName"
|
||||||
|
|
||||||
|
# 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
|
||||||
65
utils/Release-NuGetPackage/scriptsettings.json
Normal file
65
utils/Release-NuGetPackage/scriptsettings.json
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
utils/ScriptConfig.psm1
Normal file
32
utils/ScriptConfig.psm1
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
function Get-ScriptSettings {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ScriptDir,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$SettingsFileName = "scriptsettings.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
$settingsPath = Join-Path $ScriptDir $SettingsFileName
|
||||||
|
|
||||||
|
if (-not (Test-Path $settingsPath -PathType Leaf)) {
|
||||||
|
Write-Error "Settings file not found: $settingsPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Command {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Command
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "Required command '$Command' is missing. Aborting."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Get-ScriptSettings, Assert-Command
|
||||||
199
utils/TestRunner.psm1
Normal file
199
utils/TestRunner.psm1
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
PowerShell module for running tests with code coverage.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||||
|
with Coverlet code coverage collection and parsing results.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: MaksIT
|
||||||
|
Usage: Import-Module .\TestRunner.psm1
|
||||||
|
#>
|
||||||
|
|
||||||
|
function Import-LoggingModuleInternal {
|
||||||
|
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulePath = Join-Path $PSScriptRoot "Logging.psm1"
|
||||||
|
if (Test-Path $modulePath) {
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-TestRunnerLogInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Message,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
|
||||||
|
[string]$Level = "INFO"
|
||||||
|
)
|
||||||
|
|
||||||
|
Import-LoggingModuleInternal
|
||||||
|
|
||||||
|
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Log -Level $Level -Message $Message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host $Message -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-TestsWithCoverage {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs unit tests with code coverage and returns coverage metrics.
|
||||||
|
|
||||||
|
.PARAMETER TestProjectPath
|
||||||
|
Path to the test project directory.
|
||||||
|
|
||||||
|
.PARAMETER Silent
|
||||||
|
Suppress console output (for JSON consumption).
|
||||||
|
|
||||||
|
.PARAMETER ResultsDirectory
|
||||||
|
Optional fixed directory where test result files are written.
|
||||||
|
|
||||||
|
.PARAMETER KeepResults
|
||||||
|
Keep the TestResults folder after execution.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
PSCustomObject with properties:
|
||||||
|
- Success: bool
|
||||||
|
- Error: string (if failed)
|
||||||
|
- LineRate: double
|
||||||
|
- BranchRate: double
|
||||||
|
- MethodRate: double
|
||||||
|
- TotalMethods: int
|
||||||
|
- CoveredMethods: int
|
||||||
|
- CoverageFile: string
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
|
||||||
|
if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" }
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$TestProjectPath,
|
||||||
|
|
||||||
|
[switch]$Silent,
|
||||||
|
|
||||||
|
[string]$ResultsDirectory,
|
||||||
|
|
||||||
|
[switch]$KeepResults
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Resolve path
|
||||||
|
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
|
||||||
|
if (-not $TestProjectDir) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Test project not found at: $TestProjectPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
|
||||||
|
$ResultsDir = Join-Path $TestProjectDir "TestResults"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean previous results
|
||||||
|
if (Test-Path $ResultsDir) {
|
||||||
|
Remove-Item -Recurse -Force $ResultsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Silent) {
|
||||||
|
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
|
||||||
|
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests with coverage collection
|
||||||
|
Push-Location $TestProjectDir
|
||||||
|
try {
|
||||||
|
$dotnetArgs = @(
|
||||||
|
"test"
|
||||||
|
"--collect:XPlat Code Coverage"
|
||||||
|
"--results-directory", $ResultsDir
|
||||||
|
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Silent) {
|
||||||
|
$null = & dotnet @dotnetArgs 2>&1
|
||||||
|
} else {
|
||||||
|
& dotnet @dotnetArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
$testExitCode = $LASTEXITCODE
|
||||||
|
if ($testExitCode -ne 0) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Tests failed with exit code $testExitCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the coverage file
|
||||||
|
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $CoverageFile) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Coverage file not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Silent) {
|
||||||
|
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)"
|
||||||
|
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse coverage data from Cobertura XML
|
||||||
|
[xml]$coverageXml = Get-Content $CoverageFile.FullName
|
||||||
|
|
||||||
|
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
|
||||||
|
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
|
||||||
|
|
||||||
|
# Calculate method coverage from packages
|
||||||
|
$totalMethods = 0
|
||||||
|
$coveredMethods = 0
|
||||||
|
foreach ($package in $coverageXml.coverage.packages.package) {
|
||||||
|
foreach ($class in $package.classes.class) {
|
||||||
|
foreach ($method in $class.methods.method) {
|
||||||
|
$totalMethods++
|
||||||
|
if ([double]$method.'line-rate' -gt 0) {
|
||||||
|
$coveredMethods++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
|
||||||
|
|
||||||
|
# Cleanup unless KeepResults is specified
|
||||||
|
if (-not $KeepResults) {
|
||||||
|
if (Test-Path $ResultsDir) {
|
||||||
|
Remove-Item -Recurse -Force $ResultsDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return results
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $true
|
||||||
|
LineRate = $lineRate
|
||||||
|
BranchRate = $branchRate
|
||||||
|
MethodRate = $methodRate
|
||||||
|
TotalMethods = $totalMethods
|
||||||
|
CoveredMethods = $coveredMethods
|
||||||
|
CoverageFile = $CoverageFile.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Invoke-TestsWithCoverage
|
||||||
Loading…
Reference in New Issue
Block a user