Compare commits

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

46 changed files with 1118 additions and 2652 deletions

View File

@ -1,20 +1,10 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v2.0.1 - 2026-03-08
### Added
- Tests for `AddJsonOptions` integration: `ObjectResult` respects `JsonSerializerOptions.DefaultIgnoreCondition` (e.g. `WhenWritingNull`) when configured via `AddControllers().AddJsonOptions(...)`.
- Test project reorganized to mirror library structure: `ResultTests`, `ResultToActionResultTests`, and `Mvc/ObjectResultTests`.
### Fixed
- `ObjectResult` now uses the app-configured JSON options from `IOptions<JsonOptions>` when serializing response bodies; previously it always used internal defaults.
- `ObjectResult` no longer throws when `HttpContext.RequestServices` is null (e.g. in unit tests without a service provider).
## v2.0.0 - 2026-02-22 ## v2.0.0 - 2026-02-22
### Added ### Added

View File

@ -1,30 +1,31 @@
# Contributing to MaksIT.Results # Contributing to MaksIT.Results
Thank you for your interest in contributing to MaksIT.Results! This document provides guidelines for contributing to the project. Thank you for your interest in contributing to `MaksIT.Results`.
## Getting Started ## Getting Started
1. Fork the repository 1. Fork the repository.
2. Clone your fork locally 2. Clone your fork locally.
3. Create a new branch for your changes 3. Create a feature branch.
4. Make your changes 4. Implement and test your changes.
5. Submit a pull request 5. Submit a pull request to `main`.
## Development Setup ## Development Setup
### Prerequisites ### Prerequisites
- .NET 10 SDK or later - .NET 8 SDK or later
- Git - Git
- PowerShell 7+ (recommended for utility scripts)
### Building the Project ### Build
```bash ```bash
cd src cd src
dotnet build MaksIT.Results.slnx dotnet build MaksIT.Results.sln
``` ```
### Running Tests ### Test
```bash ```bash
cd src cd src
@ -33,9 +34,9 @@ dotnet test MaksIT.Results.Tests
## Commit Message Format ## Commit Message Format
This project uses the following commit message format: Use:
``` ```text
(type): description (type): description
``` ```
@ -45,176 +46,71 @@ This project uses the following commit message format:
|------|-------------| |------|-------------|
| `(feature):` | New feature or enhancement | | `(feature):` | New feature or enhancement |
| `(bugfix):` | Bug fix | | `(bugfix):` | Bug fix |
| `(refactor):` | Code refactoring without functional changes | | `(refactor):` | Refactoring without behavior change |
| `(perf):` | Performance improvement without changing behavior | | `(chore):` | Maintenance tasks (dependencies, tooling, docs) |
| `(test):` | Add or update tests |
| `(docs):` | Documentation-only changes |
| `(build):` | Build system, dependencies, packaging, or project file changes |
| `(ci):` | CI/CD pipeline or automation changes |
| `(style):` | Formatting or non-functional code style changes |
| `(revert):` | Revert a previous commit |
| `(chore):` | General maintenance tasks that do not fit the types above |
### Examples
```
(feature): add support for custom json options in object result
(bugfix): fix objectresult using app json options when request services null
(refactor): simplify result to action result conversion
(perf): reduce allocations in problem details serialization
(test): add coverage for addjsonoptions whenwritingnull
(docs): clarify json options in readme
(build): update package metadata in MaksIT.Results.csproj
(ci): update GitHub Actions workflow for .NET 10
(style): normalize using directives in mvc tests
(revert): revert breaking change in toactionresult behavior
(chore): update copyright year to 2026
```
### Guidelines ### Guidelines
- Use lowercase for the description - Use lowercase in the description.
- Keep the description concise but descriptive - Keep it concise and specific.
- No period at the end of the description - Do not end with a period.
## Code Style ## Pull Request Checklist
- Follow standard C# naming conventions 1. Ensure build and tests pass.
- Use XML documentation comments for public APIs 2. Update `README.md` if behavior or usage changed.
- Keep methods focused and single-purpose 3. Update `CHANGELOG.md` under the target version.
- Write unit tests for new functionality 4. Keep changes scoped and explain rationale in the PR description.
## Pull Request Process
1. Ensure all tests pass
2. Update documentation if needed
3. Update CHANGELOG.md with your changes under the appropriate version section
4. Submit your pull request against the `main` branch
## Versioning ## Versioning
This project follows [Semantic Versioning](https://semver.org/): This project follows [Semantic Versioning](https://semver.org/):
- **MAJOR** - Breaking changes - **MAJOR**: breaking API changes
- **MINOR** - New features (backward compatible) - **MINOR**: backward-compatible features
- **PATCH** - Bug fixes (backward compatible) - **PATCH**: backward-compatible fixes
## Release Process ## Utility Scripts
The release process is automated via PowerShell scripts in the `utils/` directory. Scripts are located under `utils/`.
### Prerequisites ### Generate Coverage Badges
- Docker Desktop running (for Linux tests)
- GitHub CLI (`gh`) installed
- Environment variables configured:
- `NUGET_MAKS_IT` - NuGet.org API key
- `GITHUB_MAKS_IT_COM` - GitHub Personal Access Token (needs `repo` scope)
### Release Scripts Overview
| Script | Purpose |
|--------|---------|
| `Generate-CoverageBadges.ps1` | Runs tests with coverage and generates SVG badges in `assets/badges/` |
| `Release-Package.ps1` | Build, test, and publish to NuGet.org and GitHub |
| `Force-AmendTaggedCommit.ps1` | Fix mistakes in tagged commits |
### Release Workflow
1. **Update version** in `MaksIT.Results/MaksIT.Results.csproj`
2. **Update CHANGELOG.md** with your changes under the target version
3. **Review and commit** all changes:
```bash
git add -A
git commit -m "(chore): release v2.x.x"
```
4. **Create version tag**:
```bash
git tag v2.x.x
```
5. **Run release script**:
```powershell
.\utils\Release-Package\Release-Package.ps1 # Full release
.\utils\Release-Package\Release-Package.ps1 -DryRun # Test without publishing
```
---
### Generate-CoverageBadges.ps1
Runs tests with coverage and generates SVG badges in `assets/badges/`. Runs tests with coverage and generates SVG badges in `assets/badges/`.
**Usage:**
```powershell ```powershell
.\utils\Generate-CoverageBadges\Generate-CoverageBadges.ps1 .\utils\Generate-CoverageBadges\Generate-CoverageBadges.ps1
``` ```
**Configuration:** `utils/Generate-CoverageBadges/scriptsettings.json` Configuration: `utils/Generate-CoverageBadges/scriptsettings.json`
--- ### Release NuGet Package
### Release-Package.ps1 Builds, tests, packs, and publishes to NuGet and GitHub release flows.
Builds, tests, packs, and publishes the package to NuGet.org and GitHub.
**What it does:**
1. Validates prerequisites and environment
2. Builds and tests the project
3. Creates NuGet package (.nupkg and .snupkg)
4. Pushes to NuGet.org
5. Creates GitHub release with assets
**Usage:**
```powershell ```powershell
.\utils\Release-Package\Release-Package.ps1 # Full release .\utils\Release-NuGetPackage\Release-NuGetPackage.ps1
.\utils\Release-Package\Release-Package.ps1 -DryRun # Test without publishing
``` ```
**Configuration:** `utils/Release-Package/scriptsettings.json` Prerequisites:
--- - Docker Desktop (for Linux test validation)
- GitHub CLI (`gh`)
- environment variable `NUGET_MAKS_IT`
- environment variable `GITHUB_MAKS_IT_COM`
### Force-AmendTaggedCommit.ps1 Configuration: `utils/Release-NuGetPackage/scriptsettings.json`
Fixes mistakes in the last tagged commit by amending it and force-pushing. ### Force Amend Tagged Commit
**When to use:** Amends the latest tagged commit and force-pushes updated branch and tag.
- You noticed an error after committing and tagging
- Need to add forgotten files to the release commit
- Need to fix a typo in the release
**What it does:**
1. Verifies the last commit has an associated tag
2. Stages all pending changes
3. Amends the latest commit (keeps existing message)
4. Deletes and recreates the tag on the amended commit
5. Force pushes the branch and tag to origin
**Usage:**
```powershell ```powershell
.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 # Amend and force push .\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1
.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun # Preview without changes .\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun
``` ```
**Warning:** This rewrites history. Only use on commits that haven't been pulled by others. Warning: this rewrites git history.
---
### Fixing a Failed Release
If the release partially failed (e.g., NuGet succeeded but GitHub failed):
1. **Re-run the release script** if it supports skipping already-completed steps
2. **If you need to fix the commit content:**
```powershell
# Make your fixes, then:
.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1
.\utils\Release-Package\Release-Package.ps1
```
## License ## License

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 - 2026 Maksym Sadovnychyy (MAKS-IT) Copyright (c) 2024 - 2025 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

View File

@ -10,7 +10,7 @@
- Static factory methods for common and extended HTTP status codes (1xx, 2xx, 3xx, 4xx, 5xx). - Static factory methods for common and extended HTTP status codes (1xx, 2xx, 3xx, 4xx, 5xx).
- Built-in conversion to `IActionResult` via `ToActionResult()`. - Built-in conversion to `IActionResult` via `ToActionResult()`.
- RFC 7807-style error payloads for failures (`application/problem+json`). - RFC 7807-style error payloads for failures (`application/problem+json`).
- Camel-case JSON serialization for response bodies; respects app-configured `JsonSerializerOptions` (e.g. `AddJsonOptions` with `DefaultIgnoreCondition.WhenWritingNull`). - Camel-case JSON serialization for response bodies.
## Installation ## Installation
@ -88,19 +88,6 @@ public sealed record UserDto(Guid Id, string Name);
- `detail` = joined `Messages` - `detail` = joined `Messages`
- content type `application/problem+json` - content type `application/problem+json`
## JSON options
`ObjectResult` uses the same `JsonSerializerOptions` as your app when you configure them with `AddJsonOptions`:
```csharp
builder.Services.AddControllers()
.AddJsonOptions(options => {
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
```
If no options are registered, a default (camel-case) serializer is used.
## Status Code Factories ## Status Code Factories
- Informational: `Result.Continue(...)`, `Result.SwitchingProtocols(...)`, `Result.Processing(...)`, etc. - Informational: `Result.Continue(...)`, `Result.SwitchingProtocols(...)`, `Result.Processing(...)`, etc.
@ -121,7 +108,6 @@ If you have any questions or need further assistance, feel free to reach out:
- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com) - **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
- **Reddit**: [MaksIT.Results: Streamline Your ASP.NET Core API Response Handling](https://www.reddit.com/r/MaksIT/comments/1f89ifn/maksitresults_streamline_your_aspnet_core_api/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) - **Reddit**: [MaksIT.Results: Streamline Your ASP.NET Core API Response Handling](https://www.reddit.com/r/MaksIT/comments/1f89ifn/maksitresults_streamline_your_aspnet_core_api/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)
## License ## License
See `LICENSE.md`. See `LICENSE.md`.

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 65.4%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 61.1%">
<title>Branch Coverage: 65.4%</title> <title>Branch Coverage: 61.1%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <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 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 x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">65.4%</text> <text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">61.1%</text>
<text x="128.75" y="14" fill="#fff">65.4%</text> <text x="128.75" y="14" fill="#fff">61.1%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 17.6%"> <svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 20.3%">
<title>Line Coverage: 17.6%</title> <title>Line Coverage: 20.3%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/> <rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#fe7d37"/> <rect x="94.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="137" height="20" fill="url(#s)"/> <rect width="137" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <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 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 x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">17.6%</text> <text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">20.3%</text>
<text x="115.75" y="14" fill="#fff">17.6%</text> <text x="115.75" y="14" fill="#fff">20.3%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 17.8%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 20.7%">
<title>Method Coverage: 17.8%</title> <title>Method Coverage: 20.7%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/> <rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#fe7d37"/> <rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="150" height="20" fill="url(#s)"/> <rect width="150" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <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 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 x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">17.8%</text> <text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">20.7%</text>
<text x="128.75" y="14" fill="#fff">17.8%</text> <text x="128.75" y="14" fill="#fff">20.7%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -15,9 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -1,67 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MaksIT.Results.Mvc;
namespace MaksIT.Results.Tests.Mvc;
public class ObjectResultTests {
[Fact]
public async Task ExecuteResultAsync_SerializesToCamelCaseJson() {
var testObject = new TestPascalCase { FirstName = "John", LastName = "Doe" };
var objectResult = new ObjectResult(testObject);
var context = new DefaultHttpContext();
var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
var actionContext = new ActionContext { HttpContext = context };
await objectResult.ExecuteResultAsync(actionContext);
memoryStream.Seek(0, SeekOrigin.Begin);
var json = await new StreamReader(memoryStream).ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Contains("\"firstName\"", json);
Assert.Contains("\"lastName\"", json);
Assert.DoesNotContain("\"FirstName\"", json);
Assert.DoesNotContain("\"LastName\"", json);
}
[Fact]
public async Task ExecuteResultAsync_WhenJsonOptionsWhenWritingNull_OmitsNullProperties() {
var services = new ServiceCollection();
services.AddOptions<JsonOptions>().Configure(o => {
o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
var serviceProvider = services.BuildServiceProvider();
var testObject = new TestWithNulls { Id = 1, Name = "Test", Optional = null };
var objectResult = new ObjectResult(testObject);
var context = new DefaultHttpContext { RequestServices = serviceProvider };
var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
var actionContext = new ActionContext { HttpContext = context };
await objectResult.ExecuteResultAsync(actionContext);
memoryStream.Seek(0, SeekOrigin.Begin);
var json = await new StreamReader(memoryStream).ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Contains("\"id\"", json);
Assert.Contains("\"name\"", json);
Assert.Contains("\"Test\"", json);
Assert.DoesNotContain("\"optional\"", json);
}
private class TestPascalCase {
public required string FirstName { get; set; }
public required string LastName { get; set; }
}
private class TestWithNulls {
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Optional { get; set; }
}
}

View File

@ -1,65 +0,0 @@
using System.Net;
namespace MaksIT.Results.Tests;
public class ResultTests {
[Fact]
public void Ok_ShouldReturnSuccess() {
var message = "Operation successful";
var result = Result.Ok(message);
Assert.True(result.IsSuccess);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
[Fact]
public void BadRequest_ShouldReturnFailure() {
var message = "Invalid request";
var result = Result.BadRequest(message);
Assert.False(result.IsSuccess);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public void Generic_Ok_ShouldReturnSuccessWithValue() {
var value = 42;
var message = "Operation successful";
var result = Result<int>.Ok(value, message);
Assert.True(result.IsSuccess);
Assert.Equal(value, result.Value);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
[Fact]
public void Generic_NotFound_ShouldReturnFailureWithNullValue() {
var message = "Resource not found";
var result = Result<string>.NotFound(null, message);
Assert.False(result.IsSuccess);
Assert.Null(result.Value);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
}
[Fact]
public void ToResultOfType_ShouldTransformValue() {
var initialValue = 42;
var transformedValue = "42";
var result = Result<int>.Ok(initialValue);
var transformedResult = result.ToResultOfType(value => value.ToString());
Assert.True(transformedResult.IsSuccess);
Assert.Equal(transformedValue, transformedResult.Value);
Assert.Equal(result.StatusCode, transformedResult.StatusCode);
}
}

View File

@ -1,48 +0,0 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
using MaksIT.Results.Mvc;
namespace MaksIT.Results.Tests;
public class ResultToActionResultTests {
[Fact]
public void ToActionResult_WhenSuccess_ReturnsStatusCodeResult() {
var result = Result.Ok("Operation successful");
var actionResult = result.ToActionResult();
Assert.IsType<StatusCodeResult>(actionResult);
var statusCodeResult = (StatusCodeResult)actionResult;
Assert.Equal((int)HttpStatusCode.OK, statusCodeResult.StatusCode);
}
[Fact]
public void ToActionResult_WhenFailure_ReturnsObjectResultWithProblemDetails() {
var errorMessage = "An error occurred";
var result = Result.BadRequest(errorMessage);
var actionResult = result.ToActionResult();
Assert.IsType<ObjectResult>(actionResult);
var objectResult = (ObjectResult)actionResult;
Assert.Equal((int)HttpStatusCode.BadRequest, objectResult.StatusCode);
Assert.IsType<ProblemDetails>(objectResult.Value);
var problemDetails = (ProblemDetails)objectResult.Value!;
Assert.Equal((int)HttpStatusCode.BadRequest, problemDetails.Status);
Assert.Equal("An error occurred", problemDetails.Title);
Assert.Equal(errorMessage, problemDetails.Detail);
}
[Fact]
public void ToActionResult_WhenGenericSuccessWithValue_ReturnsObjectResultWithValue() {
var value = new { Id = 1, Name = "Test" };
var result = Result<object>.Ok(value);
var actionResult = result.ToActionResult();
Assert.IsType<ObjectResult>(actionResult);
var objectResult = (ObjectResult)actionResult;
Assert.Equal((int)HttpStatusCode.OK, objectResult.StatusCode);
Assert.Equal(value, objectResult.Value);
}
}

View File

@ -0,0 +1,166 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using MaksIT.Results.Mvc;
namespace MaksIT.Results.Tests;
public class ResultTests {
[Fact]
public void Result_Ok_ShouldReturnSuccess() {
// Arrange
var message = "Operation successful";
// Act
var result = Result.Ok(message);
// Assert
Assert.True(result.IsSuccess);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
[Fact]
public void Result_BadRequest_ShouldReturnFailure() {
// Arrange
var message = "Invalid request";
// Act
var result = Result.BadRequest(message);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public void Result_Generic_Ok_ShouldReturnSuccessWithValue() {
// Arrange
var value = 42;
var message = "Operation successful";
// Act
var result = Result<int>.Ok(value, message);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(value, result.Value);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
[Fact]
public void Result_Generic_NotFound_ShouldReturnFailureWithNullValue() {
// Arrange
var message = "Resource not found";
// Act
var result = Result<string>.NotFound(null, message);
// Assert
Assert.False(result.IsSuccess);
Assert.Null(result.Value);
Assert.Contains(message, result.Messages);
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
}
[Fact]
public void Result_ToResultOfType_ShouldTransformValue() {
// Arrange
var initialValue = 42;
var transformedValue = "42";
var result = Result<int>.Ok(initialValue);
// Act
var transformedResult = result.ToResultOfType(value => value.ToString());
// Assert
Assert.True(transformedResult.IsSuccess);
Assert.Equal(transformedValue, transformedResult.Value);
Assert.Equal(result.StatusCode, transformedResult.StatusCode);
}
[Fact]
public void Result_ToActionResult_ShouldReturnStatusCodeResult() {
// Arrange
var result = Result.Ok("Operation successful");
// Act
var actionResult = result.ToActionResult();
// Assert
Assert.IsType<StatusCodeResult>(actionResult);
var statusCodeResult = actionResult as StatusCodeResult;
Assert.NotNull(statusCodeResult);
Assert.Equal((int)HttpStatusCode.OK, statusCodeResult.StatusCode);
}
[Fact]
public void Result_ToActionResult_ShouldReturnObjectResultForFailure() {
// Arrange
var errorMessage = "An error occurred";
var result = Result.BadRequest(errorMessage);
// Act
var actionResult = result.ToActionResult();
// Assert
Assert.IsType<ObjectResult>(actionResult);
var objectResult = actionResult as ObjectResult;
Assert.NotNull(objectResult);
Assert.Equal((int)HttpStatusCode.BadRequest, objectResult.StatusCode);
Assert.IsType<ProblemDetails>(objectResult.Value);
var problemDetails = objectResult.Value as ProblemDetails;
Assert.NotNull(problemDetails);
Assert.Equal((int)HttpStatusCode.BadRequest, problemDetails.Status);
Assert.Equal("An error occurred", problemDetails.Title);
Assert.Equal(errorMessage, problemDetails.Detail);
}
[Fact]
public void Result_Generic_ToActionResult_ShouldReturnObjectResultWithValue() {
// Arrange
var value = new { Id = 1, Name = "Test" };
var result = Result<object>.Ok(value);
// Act
var actionResult = result.ToActionResult();
// Assert
Assert.IsType<ObjectResult>(actionResult);
var objectResult = actionResult as ObjectResult;
Assert.NotNull(objectResult);
Assert.Equal((int)HttpStatusCode.OK, objectResult.StatusCode);
Assert.Equal(value, objectResult.Value);
}
[Fact]
public async Task ObjectResult_ShouldSerializeToCamelCaseJson() {
// Arrange
var testObject = new TestPascalCase { FirstName = "John", LastName = "Doe" };
var objectResult = new ObjectResult(testObject);
var context = new DefaultHttpContext();
var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
var actionContext = new ActionContext {
HttpContext = context
};
// Act
await objectResult.ExecuteResultAsync(actionContext);
// Assert
memoryStream.Seek(0, SeekOrigin.Begin);
var json = await new StreamReader(memoryStream).ReadToEndAsync();
Assert.Contains("\"firstName\"", json);
Assert.Contains("\"lastName\"", json);
Assert.DoesNotContain("\"FirstName\"", json);
Assert.DoesNotContain("\"LastName\"", json);
}
private class TestPascalCase {
public string FirstName { get; set; }
public string LastName { get; set; }
}
}

30
src/MaksIT.Results.sln Normal file
View File

@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Results", "MaksIT.Results\MaksIT.Results.csproj", "{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.Results.Tests", "MaksIT.Results.Tests\MaksIT.Results.Tests.csproj", "{68D2F460-1550-5219-355F-BEDA6C1557AA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}.Release|Any CPU.Build.0 = Release|Any CPU
{68D2F460-1550-5219-355F-BEDA6C1557AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68D2F460-1550-5219-355F-BEDA6C1557AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68D2F460-1550-5219-355F-BEDA6C1557AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68D2F460-1550-5219-355F-BEDA6C1557AA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3627A51-0642-40DB-96BC-07C627FF8ACC}
EndGlobalSection
EndGlobal

View File

@ -1,4 +0,0 @@
<Solution>
<Project Path="MaksIT.Results.Tests/MaksIT.Results.Tests.csproj" />
<Project Path="MaksIT.Results/MaksIT.Results.csproj" />
</Solution>

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@ -8,7 +8,7 @@
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>MaksIT.Results</PackageId> <PackageId>MaksIT.Results</PackageId>
<Version>2.0.1</Version> <Version>2.0.0</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.Results</Product> <Product>MaksIT.Results</Product>
@ -23,8 +23,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.9" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,36 +0,0 @@
using System.Text.Json;
namespace MaksIT.Results.Mvc;
//
// Summary:
// Options to configure Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter
// and Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonOutputFormatter.
public class JsonOptions {
public JsonOptions() {
JsonSerializerOptions = new JsonSerializerOptions();
}
//
// Summary:
// Gets or sets a flag to determine whether error messages from JSON deserialization
// by the Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter will
// be added to the Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary. If
// false, a generic error message will be used instead.
//
// Value:
// The default value is true.
//
// Remarks:
// Error messages in the Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary
// are often communicated to clients, either in HTML or using Microsoft.AspNetCore.Mvc.BadRequestObjectResult.
// In effect, this setting controls whether clients can receive detailed error messages
// about submitted JSON data.
public bool AllowInputFormatterExceptionMessages { get; set; }
//
// Summary:
// Gets the System.Text.Json.JsonSerializerOptions used by Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter
// and Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonOutputFormatter.
public JsonSerializerOptions JsonSerializerOptions { get; }
}

View File

@ -1,7 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace MaksIT.Results.Mvc; namespace MaksIT.Results.Mvc;
@ -16,10 +14,6 @@ public class ObjectResult(object? value) : IActionResult {
public async Task ExecuteResultAsync(ActionContext context) { public async Task ExecuteResultAsync(ActionContext context) {
var response = context.HttpContext.Response; var response = context.HttpContext.Response;
// Prefer app-configured JSON options (from AddJsonOptions), fall back to default
var jsonOptions = context.HttpContext.RequestServices?.GetService<IOptions<JsonOptions>>()?.Value?.JsonSerializerOptions
?? _jsonSerializerOptions;
if (StatusCode.HasValue) { if (StatusCode.HasValue) {
response.StatusCode = StatusCode.Value; response.StatusCode = StatusCode.Value;
} }
@ -37,7 +31,7 @@ public class ObjectResult(object? value) : IActionResult {
response.Body, response.Body,
Value, Value,
Value?.GetType() ?? typeof(object), Value?.GetType() ?? typeof(object),
jsonOptions _jsonSerializerOptions
); );
} }
} }

View File

@ -1,3 +1,3 @@
@echo off @echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
pause pause

View File

@ -1,9 +1,6 @@
#requires -Version 7.0
#requires -PSEdition Core
<# <#
.SYNOPSIS .SYNOPSIS
Amends the latest commit, recreates its associated tag, and force pushes both to remote. Amends the latest tagged commit and force-pushes updated branch and tag.
.DESCRIPTION .DESCRIPTION
This script performs the following operations: This script performs the following operations:
@ -19,10 +16,10 @@
If specified, shows what would be done without making changes. If specified, shows what would be done without making changes.
.EXAMPLE .EXAMPLE
pwsh -File .\Force-AmendTaggedCommit.ps1 .\Force-AmendTaggedCommit.ps1
.EXAMPLE .EXAMPLE
pwsh -File .\Force-AmendTaggedCommit.ps1 -DryRun .\Force-AmendTaggedCommit.ps1 -DryRun
.NOTES .NOTES
CONFIGURATION (scriptsettings.json): CONFIGURATION (scriptsettings.json):
@ -69,29 +66,6 @@ Import-Module $gitToolsModulePath -Force
#endregion #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 #region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir $settings = Get-ScriptSettings -ScriptDir $scriptDir
@ -136,17 +110,14 @@ Write-Log -Level "INFO" -Message "Commit: $CommitHash - $CommitMessage"
# 3. Ensure HEAD has at least one tag # 3. Ensure HEAD has at least one tag
Write-LogStep "Finding tag on last commit..." Write-LogStep "Finding tag on last commit..."
$tags = Get-HeadTags $tags = @(Get-HeadTags)
if ($tags.Count -eq 0) { 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." Write-Error "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
exit 1 exit 1
} }
# If multiple tags exist, choose the latest one on HEAD by git ordering. # If multiple tags exist, use the first one returned by git.
if ($tags.Count -gt 1) { $TagName = $tags[0]
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" Write-Log -Level "OK" -Message "Found tag: $TagName"
# 4. Inspect pending changes before amend # 4. Inspect pending changes before amend

View File

@ -1,3 +1,3 @@
@echo off @echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
pause pause

View File

@ -1,13 +1,11 @@
#requires -Version 7.0
#requires -PSEdition Core
<# <#
.SYNOPSIS .SYNOPSIS
Generates SVG coverage badges for README. Runs tests, collects coverage, and generates SVG badges for README.
.DESCRIPTION .DESCRIPTION
This script runs unit tests via TestRunner.psm1, then generates shields.io-style This script runs unit tests via TestRunner.psm1, then generates shields.io-style
SVG badges for line, branch, and method coverage. SVG badges for line, branch, and method coverage.
Optional HTML report generation is controlled by scriptsettings.json (openReport).
Configuration is stored in scriptsettings.json: Configuration is stored in scriptsettings.json:
- openReport : Generate and open full HTML report (true/false) - openReport : Generate and open full HTML report (true/false)
@ -23,7 +21,7 @@
dotnet tool install -g dotnet-reportgenerator-globaltool dotnet tool install -g dotnet-reportgenerator-globaltool
.EXAMPLE .EXAMPLE
pwsh -File .\Generate-CoverageBadges.ps1 .\Generate-CoverageBadges.ps1
Runs tests and generates coverage badges (and optionally HTML report if configured). Runs tests and generates coverage badges (and optionally HTML report if configured).
.OUTPUTS .OUTPUTS
@ -188,7 +186,7 @@ foreach ($badge in $Settings.badges) {
$color = Get-BadgeColor $metricValue $color = Get-BadgeColor $metricValue
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color $svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
$path = Join-Path $BadgesDir $badge.name $path = Join-Path $BadgesDir $badge.name
$svg | Out-File -FilePath $path -Encoding utf8NoBOM $svg | Out-File -FilePath $path -Encoding utf8
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%" Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
} }

View File

@ -1,6 +1,3 @@
#requires -Version 7.0
#requires -PSEdition Core
# #
# Shared Git helpers for utility scripts. # Shared Git helpers for utility scripts.
# #

View File

@ -1,6 +1,3 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-LogTimestampInternal { function Get-LogTimestampInternal {
return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
} }

View File

@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
pause

View File

@ -0,0 +1,770 @@
<#
.SYNOPSIS
Builds, tests, packs, and publishes MaksIT.Core to NuGet and GitHub releases.
.DESCRIPTION
This script automates the release process for MaksIT.Core library.
The script is IDEMPOTENT - you can safely re-run it if any step fails.
It will skip already-completed steps (NuGet and GitHub) and only create what's missing.
GitHub repository target can be configured explicitly in scriptsettings.json.
Features:
- Validates environment and prerequisites
- Checks if version already exists on NuGet.org (skips if released)
- Checks if GitHub release exists (skips if released)
- Scans for vulnerable packages (security check)
- Builds and tests the project (Windows + Linux via Docker)
- Collects code coverage with Coverlet (threshold enforcement optional)
- Generates test result artifacts (TRX format) and coverage reports
- Displays test results with pass/fail counts and coverage percentage
- Publishes to NuGet.org
- Creates a GitHub release with changelog and package assets
- Shows timing summary for all steps
.REQUIREMENTS
Environment Variables:
- NUGET_MAKS_IT : NuGet.org API key for publishing packages
- GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope)
Tools (Required):
- dotnet CLI : For building, testing, and packing
- git : For version control operations
- gh (GitHub CLI) : For creating GitHub releases
- docker : For cross-platform Linux testing
.WORKFLOW
1. VALIDATION PHASE
- Check required environment variables (NuGet key, GitHub token)
- Check required tools are installed (dotnet, git, gh, docker)
- Verify no uncommitted changes in working directory
- Authenticate GitHub CLI
2. VERSION & RELEASE CHECK PHASE (Idempotent)
- Read latest version from CHANGELOG.md
- Find commit with matching version tag
- Validate tag is on configured release branch (from scriptsettings.json)
- Check if already released on NuGet.org (mark for skip if yes)
- Check if GitHub release exists (mark for skip if yes)
- Read target framework from MaksIT.Core.csproj
- Extract release notes from CHANGELOG.md for current version
3. SECURITY SCAN
- Check for vulnerable packages (dotnet list package --vulnerable)
- Fail or warn based on $failOnVulnerabilities setting
4. BUILD & TEST PHASE
- Clean previous builds (delete bin/obj folders)
- Restore NuGet packages
- Windows: Build main project -> Build test project -> Run tests with coverage
- Analyze code coverage (fail if below threshold when configured)
- Linux (Docker): Build main project -> Build test project -> Run tests (TRX report)
- Rebuild for Windows (Docker may overwrite bin/obj)
- Create NuGet package (.nupkg) and symbols (.snupkg)
- All steps are timed for performance tracking
5. CONFIRMATION PHASE
- Display release summary
- Prompt user for confirmation before proceeding
6. NUGET RELEASE PHASE (Idempotent)
- Skip if version already exists on NuGet.org
- Otherwise, push package to NuGet.org
7. GITHUB RELEASE PHASE (Idempotent)
- Skip if release already exists
- Push tag to remote if not already there
- Create GitHub release with:
* Release notes from CHANGELOG.md
* .nupkg and .snupkg as downloadable assets
8. COMPLETION PHASE
- Display timing summary for all steps
- Display test results summary
- Display success summary with links
- Open NuGet and GitHub release pages in browser
- TODO: Email notification (template provided)
- TODO: Package signing (template provided)
.USAGE
Before running:
1. Ensure Docker Desktop is running (for Linux tests)
2. Update version in MaksIT.Core.csproj
3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md
4. Review and commit all changes
5. Create version tag: git tag v1.x.x
6. Run: .\Release-NuGetPackage.ps1
Note: The script finds the commit with the tag matching CHANGELOG.md version.
You can run it from any branch/commit - it releases the tagged commit.
Re-run release (idempotent - skips NuGet/GitHub if already released):
.\Release-NuGetPackage.ps1
Generate changelog and update LICENSE year:
.\Generate-Changelog.ps1
.CONFIGURATION
All settings are stored in scriptsettings.json:
- packageSigning: Code signing certificate configuration
- emailNotification: SMTP settings for release notifications
.NOTES
Author: Maksym Sadovnychyy (MAKS-IT)
Repository: https://github.com/MAKS-IT-COM/maksit-core
#>
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
# - release branch -> Full release to GitHub (tag required, clean working directory)
# Get the directory of the current script (for loading settings and relative paths)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
#region Import Modules
# Import TestRunner module
$utilsDir = Split-Path $scriptDir -Parent
$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1"
if (-not (Test-Path $testRunnerModulePath)) {
Write-Error "TestRunner module not found at: $testRunnerModulePath"
exit 1
}
Import-Module $testRunnerModulePath -Force
# Import ScriptConfig module
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
Import-Module $scriptConfigModulePath -Force
# Import Logging module
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
}
Import-Module $loggingModulePath -Force
# Import GitTools module
$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
if (-not (Test-Path $gitToolsModulePath)) {
Write-Error "GitTools module not found at: $gitToolsModulePath"
exit 1
}
Import-Module $gitToolsModulePath -Force
#endregion
#region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir
#endregion
#region Configuration
# GitHub configuration
$githubReleseEnabled = $settings.github.enabled
$githubTokenEnvVar = $settings.github.githubToken
$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
$githubRepositorySetting = $settings.github.repository
# NuGet configuration
$nugetReleseEnabled = $settings.nuget.enabled
$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" }
# Paths from settings (resolve relative to script directory)
$csprojPaths = @()
$rawCsprojPaths = @()
if ($settings.paths.csprojPaths) {
if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) {
$rawCsprojPaths += $settings.paths.csprojPaths
}
else {
$rawCsprojPaths += $settings.paths.csprojPaths
}
}
else {
Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json."
exit 1
}
foreach ($path in $rawCsprojPaths) {
if ([string]::IsNullOrWhiteSpace($path)) {
continue
}
$resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
$csprojPaths += $resolvedPath
}
if ($csprojPaths.Count -eq 0) {
Write-Error "No valid csproj paths configured in scriptsettings.json."
exit 1
}
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir))
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
# Release naming patterns
$zipNamePattern = $settings.release.zipNamePattern
$releaseTitlePattern = $settings.release.releaseTitlePattern
# Branch configuration
$releaseBranch = $settings.branches.release
$devBranch = $settings.branches.dev
#endregion
#region Helpers
# Helper: extract a csproj property (first match)
function Get-CsprojPropertyValue {
param(
[Parameter(Mandatory=$true)][xml]$csproj,
[Parameter(Mandatory=$true)][string]$propertyName
)
$propNode = $csproj.Project.PropertyGroup |
Where-Object { $_.$propertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$propertyName
}
return $null
}
# Helper: resolve output assembly name for published exe
function Resolve-ProjectExeName {
param(
[Parameter(Mandatory=$true)][string]$projPath
)
[xml]$csproj = Get-Content $projPath
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
if ($assemblyName) {
return $assemblyName
}
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
}
# Helper: check for uncommitted changes
function Assert-WorkingTreeClean {
param(
[Parameter(Mandatory = $true)]
[bool]$IsReleaseBranch
)
$gitStatus = Get-GitStatusShort
if ($gitStatus) {
if ($IsReleaseBranch) {
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
Write-Log -Level "WARN" -Message "Uncommitted files:"
$gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
exit 1
}
else {
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
}
}
else {
Write-Log -Level "OK" -Message " Working directory is clean."
}
}
# Helper: read versions from csproj files
function Get-CsprojVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$CsprojPaths
)
Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..."
$projectVersions = @{}
foreach ($projPath in $CsprojPaths) {
if (-not (Test-Path $projPath -PathType Leaf)) {
Write-Error "Csproj file not found at: $projPath"
exit 1
}
if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") {
Write-Error "Configured path is not a .csproj file: $projPath"
exit 1
}
[xml]$csproj = Get-Content $projPath
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projPath"
exit 1
}
$projectVersions[$projPath] = $version
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version"
}
return $projectVersions
}
# Helper: resolve GitHub repository (owner/repo) from settings override or remote URL
function Resolve-GitHubRepository {
param(
[Parameter(Mandatory = $false)]
[string]$RepositorySetting
)
if (-not [string]::IsNullOrWhiteSpace($RepositorySetting)) {
$value = $RepositorySetting.Trim()
if ($value -match '^https?://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+?)(?:\.git)?/?$') {
return "$($Matches['owner'])/$($Matches['repo'])"
}
if ($value -match '^(?<owner>[^/]+)/(?<repo>[^/]+)$') {
return "$($Matches['owner'])/$($Matches['repo'])"
}
Write-Error "Invalid github.repository format '$value'. Use 'owner/repo' or 'https://github.com/owner/repo'."
exit 1
}
$remoteUrl = git config --get remote.origin.url
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
Write-Error "Could not determine git remote origin URL. Configure github.repository in scriptsettings.json."
exit 1
}
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
return "$($Matches['owner'])/$($Matches['repo'])"
}
Write-Error "Could not parse repository from remote URL: $remoteUrl. Configure github.repository in scriptsettings.json."
exit 1
}
#endregion
#region Validate CLI Dependencies
Assert-Command dotnet
Assert-Command git
Assert-Command docker
# gh command check deferred until after branch detection (only needed on release branch)
#endregion
#region Main
Write-Log -Level "STEP" -Message "=================================================="
Write-Log -Level "STEP" -Message "RELEASE BUILD"
Write-Log -Level "STEP" -Message "=================================================="
#region Preflight
$isDevBranch = $false
$isReleaseBranch = $false
# 1. Detect current branch and determine release mode
$currentBranch = Get-CurrentBranch
$isDevBranch = $currentBranch -eq $devBranch
$isReleaseBranch = $currentBranch -eq $releaseBranch
if (-not $isDevBranch -and -not $isReleaseBranch) {
Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
exit 1
}
# 2. Check for uncommitted changes (required on release branch, allowed on dev)
Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch
# 3. Get version from csproj (source of truth)
$projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths
# Use the first project's version as the release version
$version = $projectVersions[$csprojPaths[0]]
# 4. Handle tag based on branch
if ($isReleaseBranch) {
# Release branch: tag is required and must match version
$tag = Get-CurrentCommitTag -Version $version
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
exit 1
}
$tagVersion = $Matches[1]
if ($tagVersion -ne $version) {
Write-Error "Tag version ($tagVersion) does not match csproj version ($version)."
Write-Log -Level "WARN" -Message " Either update the tag or the csproj version."
exit 1
}
Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)"
}
else {
# Dev branch: no tag required, use version from csproj
$tag = "v$version"
Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)."
}
# 5. Verify CHANGELOG.md has matching version entry
Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..."
if (-not (Test-Path $changelogPath)) {
Write-Error "CHANGELOG.md not found at: $changelogPath"
exit 1
}
$changelog = Get-Content $changelogPath -Raw
if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
Write-Error "No version entry found in CHANGELOG.md"
exit 1
}
$changelogVersion = $Matches[1]
if ($changelogVersion -ne $version) {
Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version."
exit 1
}
Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion"
Write-Log -Level "OK" -Message "All pre-flight checks passed!"
#endregion
#region Test
Write-Log -Level "STEP" -Message "Running tests..."
# Run tests using TestRunner module
$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent
if (-not $testResult.Success) {
Write-Error "Tests failed. Release aborted."
Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)"
exit 1
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
#endregion
#region Build And Publish
# 7. Prepare staging directory
Write-Log -Level "STEP" -Message "Preparing staging directory..."
if (Test-Path $stagingDir) {
Remove-Item $stagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stagingDir | Out-Null
$binDir = Join-Path $stagingDir "bin"
# 8. Publish the project to staging/bin
Write-Log -Level "STEP" -Message "Publishing projects to bin folder..."
$publishSuccess = $true
$publishedProjects = @()
foreach ($projPath in $csprojPaths) {
$projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
$projBinDir = Join-Path $binDir $projName
dotnet publish $projPath -c Release -o $projBinDir
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed for $projName."
$publishSuccess = $false
}
else {
$exeBaseName = Resolve-ProjectExeName -projPath $projPath
$publishedProjects += [PSCustomObject]@{
ProjPath = $projPath
ProjName = $projName
BinDir = $projBinDir
ExeBaseName = $exeBaseName
}
Write-Log -Level "OK" -Message " Published $projName successfully to: $projBinDir"
}
}
if (-not $publishSuccess) {
exit 1
}
# 12. Prepare release directory
if (!(Test-Path $releaseDir)) {
New-Item -ItemType Directory -Path $releaseDir | Out-Null
}
# 13. Create zip file
$zipName = $zipNamePattern
$zipName = $zipName -replace '\{version\}', $version
$zipPath = Join-Path $releaseDir $zipName
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force
}
Write-Log -Level "STEP" -Message "Creating archive $zipName..."
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
if (-not (Test-Path $zipPath)) {
Write-Error "Failed to create archive $zipPath"
exit 1
}
Write-Log -Level "OK" -Message " Archive created: $zipPath"
# 14. Pack NuGet package and resolve produced .nupkg file
$packageProjectPath = $csprojPaths[0]
Write-Log -Level "STEP" -Message "Packing NuGet package..."
dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet pack failed for $packageProjectPath."
exit 1
}
$packageFile = Get-ChildItem -Path $releaseDir -Filter "*.nupkg" |
Where-Object {
$_.Name -like "*$version*.nupkg" -and
$_.Name -notlike "*.symbols.nupkg" -and
$_.Name -notlike "*.snupkg"
} |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $packageFile) {
Write-Error "Could not locate generated NuGet package for version $version in: $releaseDir"
exit 1
}
Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
# 15. Extract release notes from CHANGELOG.md
Write-Log -Level "STEP" -Message "Extracting release notes..."
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
$match = [regex]::Match($changelog, $pattern)
if (-not $match.Success) {
Write-Error "Changelog entry for version $version not found."
exit 1
}
$releaseNotes = $match.Value.Trim()
Write-Log -Level "OK" -Message " Release notes extracted."
# 16. Resolve repository info for GitHub release
$repo = Resolve-GitHubRepository -RepositorySetting $githubRepositorySetting
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "STEP" -Message "Release Summary:"
Write-Log -Level "INFO" -Message " Repository: $repo"
Write-Log -Level "INFO" -Message " Tag: $tag"
Write-Log -Level "INFO" -Message " Title: $releaseName"
# 17. Check if tag is pushed to remote (skip on dev branch)
if (-not $isDevBranch) {
Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
$remoteTagExists = Test-RemoteTagExists -Tag $tag -Remote "origin"
if (-not $remoteTagExists) {
Write-Log -Level "WARN" -Message " Tag $tag not found on remote. Pushing..."
Push-TagToRemote -Tag $tag -Remote "origin"
}
else {
Write-Log -Level "OK" -Message " Tag exists on remote."
}
# Release to GitHub
if ($githubReleseEnabled) {
Write-Log -Level "STEP" -Message " Release branch ($releaseBranch) - will publish to GitHub."
Assert-Command gh
# 6. Check GitHub authentication
Write-Log -Level "INFO" -Message "Checking GitHub authentication..."
if (-not $githubToken) {
Write-Error "GitHub token is not set. Set '$githubTokenEnvVar' and rerun."
exit 1
}
# gh release subcommands do not support custom auth headers.
# Scope GH_TOKEN to this block so all gh commands authenticate with the configured token.
$previousGhToken = $env:GH_TOKEN
$env:GH_TOKEN = $githubToken
try {
$authTest = & gh api user 2>$null
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope."
exit 1
}
Write-Log -Level "OK" -Message " GitHub CLI authenticated."
# 18. Create or update GitHub release
Write-Log -Level "STEP" -Message "Creating GitHub release..."
# Check if release already exists
$releaseViewArgs = @(
"release", "view", $tag,
"--repo", $repo
)
& gh @releaseViewArgs 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..."
$releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes")
& gh @releaseDeleteArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to delete existing release $tag."
exit 1
}
}
# Create release using the existing tag
# Write release notes to a temp file to avoid shell interpretation issues with special characters
$notesFilePath = Join-Path $releaseDir "release-notes-temp.md"
[System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false))
$createReleaseArgs = @(
"release", "create", $tag, $zipPath
"--repo", $repo
"--title", $releaseName
"--notes-file", $notesFilePath
)
& gh @createReleaseArgs
$ghExitCode = $LASTEXITCODE
# Cleanup temp notes file
if (Test-Path $notesFilePath) {
Remove-Item $notesFilePath -Force
}
if ($ghExitCode -ne 0) {
Write-Error "Failed to create GitHub release for tag $tag."
exit 1
}
}
finally {
if ($null -ne $previousGhToken) {
$env:GH_TOKEN = $previousGhToken
}
else {
Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue
}
}
Write-Log -Level "OK" -Message " GitHub release created successfully."
}
else {
Write-Log -Level "WARN" -Message "Skipping GitHub release (disabled)."
}
# Release to NuGet
if ($nugetReleseEnabled) {
Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to push the package to NuGet."
exit 1
}
Write-Log -Level "OK" -Message " NuGet push completed."
}
else {
Write-Log -Level "WARN" -Message "Skipping NuGet publish (disabled)."
}
}
else {
Write-Log -Level "WARN" -Message "Skipping remote tag verification and GitHub release (dev branch)."
}
#endregion
#region Cleanup
if (Test-Path $stagingDir) {
Remove-Item $stagingDir -Recurse -Force
Write-Log -Level "INFO" -Message " Cleaned up staging directory."
}
if (Test-Path $testResultsDir) {
Remove-Item $testResultsDir -Recurse -Force
Write-Log -Level "INFO" -Message " Cleaned up test results directory."
}
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

View File

@ -0,0 +1,67 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release NuGet Package Script Settings",
"description": "Configuration file for Release-NuGetPackage.ps1 script.",
"github": {
"enabled": true,
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-results"
},
"nuget": {
"enabled": true,
"nugetApiKey": "NUGET_MAKS_IT",
"source": "https://api.nuget.org/v3/index.json"
},
"branches": {
"release": "main",
"dev": "dev"
},
"paths": {
"csprojPaths": [
"..\\..\\src\\MaksIT.Results\\MaksIT.Results.csproj"
],
"testResultsDir": "..\\..\\testResults",
"stagingDir": "..\\..\\staging",
"releaseDir": "..\\..\\release",
"changelogPath": "..\\..\\CHANGELOG.md",
"testProject": "..\\..\\src\\MaksIT.Results.Tests"
},
"release": {
"zipNamePattern": "maksit.results-{version}.zip",
"releaseTitlePattern": "Release {version}"
},
"_comments": {
"github": {
"enabled": "Enable/disable GitHub release creation.",
"githubToken": "Environment variable name containing GitHub token used by gh CLI.",
"repository": "GitHub repository override used for releases (supports owner/repo or full GitHub URL)."
},
"nuget": {
"enabled": "Enable/disable NuGet publish step.",
"nugetApiKey": "Environment variable name containing NuGet API key.",
"source": "NuGet feed URL passed to dotnet nuget push."
},
"branches": {
"release": "Branch that requires tag and allows full publish flow.",
"dev": "Branch for local/dev build flow (no tag required)."
},
"paths": {
"csprojPaths": "List of project files used for version discovery and publish output.",
"testResultsDir": "Directory where test artifacts are written.",
"stagingDir": "Temporary staging directory before archive creation.",
"releaseDir": "Output directory for release archives and artifacts.",
"changelogPath": "Path to CHANGELOG.md used for version and release notes extraction.",
"testProject": "Test project path used by TestRunner."
},
"release": {
"zipNamePattern": "Archive name pattern. Supports {version} placeholder.",
"releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder."
}
}
}

View File

@ -1,121 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Cleanup plugin for removing generated artifacts after pipeline completion.
.DESCRIPTION
This plugin removes files from the configured artifacts directory using
glob patterns. It is typically placed at the end of the Release stage so
cleanup becomes explicit and opt-in per repository.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-CleanupPatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @('*.nupkg', '*.snupkg')
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @('*.nupkg', '*.snupkg')
}
return @([string]$ConfiguredPatterns)
}
function Get-ExcludePatternsInternal {
param(
[Parameter(Mandatory = $false)]
$ConfiguredPatterns
)
if ($null -eq $ConfiguredPatterns) {
return @()
}
if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) {
return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) {
return @()
}
return @([string]$ConfiguredPatterns)
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns
$excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "CleanupArtifacts plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory"
return
}
Write-Log -Level "STEP" -Message "Cleaning generated artifacts..."
$itemsToRemove = @()
foreach ($pattern in $patterns) {
$matchedItems = @(
Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern }
)
if ($excludePatterns.Count -gt 0) {
$matchedItems = @(
$matchedItems |
Where-Object {
$item = $_
-not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1)
}
)
}
$itemsToRemove += @($matchedItems)
}
$itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique)
if ($itemsToRemove.Count -eq 0) {
Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules."
return
}
foreach ($item in $itemsToRemove) {
Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue
Write-Log -Level "OK" -Message " Removed: $($item.Name)"
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,93 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Creates a release zip from prepared build artifacts.
.DESCRIPTION
This plugin compresses the release artifact inputs prepared by an earlier
producer plugin (for example DotNetPack or DotNetPublish) into a zip file
and exposes the resulting release assets for later publisher plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$archiveInputs = @()
if ($sharedSettings.PSObject.Properties['ReleaseArchiveInputs'] -and $sharedSettings.ReleaseArchiveInputs) {
$archiveInputs = @($sharedSettings.ReleaseArchiveInputs)
}
elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) {
$archiveInputs = @($sharedSettings.PackageFile.FullName)
if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) {
$archiveInputs += $sharedSettings.SymbolsPackageFile.FullName
}
}
if ($archiveInputs.Count -eq 0) {
throw "CreateArchive plugin requires prepared artifacts. Run a producer plugin (for example DotNetPack or DotNetPublish) first."
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "CreateArchive plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$zipNamePattern = if ($pluginSettings.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.zipNamePattern)) {
[string]$pluginSettings.zipNamePattern
}
else {
"release-{version}.zip"
}
$zipFileName = $zipNamePattern -replace '\{version\}', $version
$zipPath = Join-Path $artifactsDirectory $zipFileName
if (Test-Path $zipPath) {
Remove-Item -Path $zipPath -Force
}
Write-Log -Level "STEP" -Message "Creating release archive..."
Compress-Archive -Path $archiveInputs -DestinationPath $zipPath -CompressionLevel Optimal -Force
if (-not (Test-Path $zipPath -PathType Leaf)) {
throw "Failed to create release archive at: $zipPath"
}
Write-Log -Level "OK" -Message " Release archive ready: $zipPath"
$releaseAssetPaths = @($zipPath)
if ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) {
$releaseAssetPaths += $sharedSettings.PackageFile.FullName
}
if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName
}
$sharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseArchivePath -NotePropertyValue $zipPath -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,99 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET pack plugin for producing package artifacts.
.DESCRIPTION
This plugin creates package output for the release pipeline.
It packs the configured .NET project, resolves the generated
package artifacts, and publishes them into shared runtime context
for later plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# Load this globally only as a fallback. Re-importing PluginSupport in its own execution path
# can invalidate commands already resolved by the release engine.
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$packageProjectPath = $null
$releaseArchiveInputs = @()
Assert-Command dotnet
if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPack plugin requires project files in the shared context."
}
$outputDir = $artifactsDirectory
if (!(Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# The release context guarantees ProjectFiles is an array, so index 0 is the first project path,
# not the first character of a string.
$packageProjectPath = $projectFiles[0]
Write-Log -Level "STEP" -Message "Packing NuGet package..."
dotnet pack $packageProjectPath -c Release -o $outputDir --nologo `
-p:IncludeSymbols=true `
-p:SymbolPackageFormat=snupkg
if ($LASTEXITCODE -ne 0) {
throw "dotnet pack failed for $packageProjectPath."
}
# dotnet pack can leave older packages in the artifacts directory.
# Pick the newest file matching the current version rather than assuming a clean folder.
$packageFile = Get-ChildItem -Path $outputDir -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) {
throw "Could not locate generated NuGet package for version $version in: $outputDir"
}
Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
$releaseArchiveInputs = @($packageFile.FullName)
$symbolsPackageFile = Get-ChildItem -Path $outputDir -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)"
$releaseArchiveInputs += $symbolsPackageFile.FullName
}
else {
Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $packageFile -Force
$sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,71 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET publish plugin for producing application release artifacts.
.DESCRIPTION
This plugin publishes the configured .NET project into a release output
directory and exposes that published directory to the shared release
context so later release-stage plugins can archive and publish it.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$publishProjectPath = $null
Assert-Command dotnet
if (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPublish plugin requires project files in the shared context."
}
if (!(Test-Path $artifactsDirectory)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
# The first configured project remains the canonical release artifact source.
$publishProjectPath = $projectFiles[0]
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force
}
Write-Log -Level "STEP" -Message "Publishing release artifact..."
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for $publishProjectPath."
}
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
if ($publishedItems.Count -eq 0) {
throw "dotnet publish completed, but no files were produced in: $publishDir"
}
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
$sharedSettings | Add-Member -NotePropertyName PackageFile -NotePropertyValue $null -Force
$sharedSettings | Add-Member -NotePropertyName SymbolsPackageFile -NotePropertyValue $null -Force
$sharedSettings | Add-Member -NotePropertyName ReleaseArchiveInputs -NotePropertyValue @($publishDir) -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,72 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET test plugin for executing automated tests.
.DESCRIPTION
This plugin resolves the configured .NET test project and optional
results directory, runs tests through TestRunner, and stores
the resulting test metrics in shared runtime context.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# Same fallback pattern as the other plugins: use the existing shared module if it is already loaded.
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$testProjectSetting = $pluginSettings.project
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.ScriptDir
if ([string]::IsNullOrWhiteSpace($testProjectSetting)) {
throw "DotNetTest plugin requires 'project' in scriptsettings.json."
}
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testProjectSetting))
$testResultsDir = $null
if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting))
}
Write-Log -Level "STEP" -Message "Running tests..."
# Build a splatted hashtable so optional arguments can be added without duplicating the call site.
$invokeTestParams = @{
TestProjectPath = $testProjectPath
Silent = $true
}
if ($testResultsDir) {
$invokeTestParams.ResultsDirectory = $testResultsDir
}
$testResult = Invoke-TestsWithCoverage @invokeTestParams
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName TestResult -NotePropertyValue $testResult -Force
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)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,232 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
GitHub release plugin.
.DESCRIPTION
This plugin validates GitHub CLI access, resolves the target
repository, and creates the configured GitHub release using the
shared release artifacts and extracted release notes.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-GitHubRepositoryInternal {
param(
[Parameter(Mandatory = $false)]
[string]$ConfiguredRepository
)
$repoSource = $ConfiguredRepository
if ([string]::IsNullOrWhiteSpace($repoSource)) {
$repoSource = git config --get remote.origin.url
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoSource)) {
throw "Could not determine git remote origin URL."
}
}
$repoSource = $repoSource.Trim()
if ($repoSource -match "(?i)github\.com[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
return "$($matches['owner'])/$($matches['repo'])"
}
if ($repoSource -match "^(?<owner>[^/]+)/(?<repo>[^/]+)$") {
return "$($matches['owner'])/$($matches['repo'])"
}
throw "Could not parse GitHub repo from source: $repoSource. Configure Plugins[].repository with 'owner/repo' or a GitHub URL."
}
function Get-ReleaseNotesInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesFile,
[Parameter(Mandatory = $true)]
[string]$Version
)
Write-Log -Level "INFO" -Message "Verifying release notes source..."
if (-not (Test-Path $ReleaseNotesFile -PathType Leaf)) {
throw "Release notes source file not found at: $ReleaseNotesFile"
}
$releaseNotesContent = Get-Content $ReleaseNotesFile -Raw
if ($releaseNotesContent -notmatch '##\s+v(\d+\.\d+\.\d+)') {
throw "No version entry found in the configured release notes source."
}
$releaseNotesVersion = $Matches[1]
if ($releaseNotesVersion -ne $Version) {
throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)."
}
Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion"
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($releaseNotesContent, $pattern)
if (-not $match.Success) {
throw "Release notes entry for version $Version not found."
}
Write-Log -Level "OK" -Message " Release notes extracted."
return $match.Value.Trim()
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$githubTokenEnvVar = $pluginSettings.githubToken
$configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
$scriptDir = $sharedSettings.ScriptDir
$version = $sharedSettings.Version
$tag = $sharedSettings.Tag
$releaseDir = $sharedSettings.ReleaseDir
$releaseAssetPaths = @()
Assert-Command gh
if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) {
throw "GitHub plugin requires 'githubToken' in scriptsettings.json."
}
$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
if ([string]::IsNullOrWhiteSpace($githubToken)) {
throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun."
}
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptsettings.json."
}
$releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting))
$releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version
if ($sharedSettings.PSObject.Properties['ReleaseAssetPaths'] -and $sharedSettings.ReleaseAssetPaths) {
$releaseAssetPaths = @($sharedSettings.ReleaseAssetPaths)
}
elseif ($sharedSettings.PSObject.Properties['PackageFile'] -and $sharedSettings.PackageFile) {
$releaseAssetPaths = @($sharedSettings.PackageFile.FullName)
if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.SymbolsPackageFile.FullName
}
}
if ($releaseAssetPaths.Count -eq 0) {
throw "GitHub release requires at least one prepared release asset."
}
$repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository
$releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) {
"Release {version}"
}
else {
$releaseTitlePatternSetting
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message " GitHub repository: $repo"
Write-Log -Level "INFO" -Message " GitHub tag: $tag"
Write-Log -Level "INFO" -Message " GitHub title: $releaseName"
$previousGhToken = $env:GH_TOKEN
$env:GH_TOKEN = $githubToken
try {
$ghVersion = & gh --version 2>&1
if ($ghVersion) {
Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])"
}
Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)"
$authArgs = @("api", "repos/$repo", "--jq", ".full_name")
$authOutput = & gh @authArgs 2>&1
$authExitCode = $LASTEXITCODE
if ($authExitCode -ne 0 -or [string]::IsNullOrWhiteSpace(($authOutput | Out-String))) {
Write-Log -Level "WARN" -Message " gh auth check failed (exit code: $authExitCode)."
if ($authOutput) {
$authOutput | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
}
$authStatus = & gh auth status --hostname github.com 2>&1
if ($authStatus) {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
}
throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository."
}
Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"
Write-Log -Level "STEP" -Message "Creating GitHub release..."
$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) {
throw "Failed to delete existing release $tag."
}
}
$notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version)
try {
[System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false))
$createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @(
"--repo", $repo,
"--title", $releaseName,
"--notes-file", $notesFilePath
)
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to create GitHub release for tag $tag."
}
}
finally {
if (Test-Path $notesFilePath) {
Remove-Item $notesFilePath -Force
}
}
Write-Log -Level "OK" -Message " GitHub release created successfully."
$sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force
}
finally {
if ($null -ne $previousGhToken) {
$env:GH_TOKEN = $previousGhToken
}
else {
Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue
}
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,67 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
NuGet publish plugin.
.DESCRIPTION
This plugin publishes the package artifact from shared runtime
context to the configured NuGet feed using the configured API key.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$nugetApiKeyEnvVar = $pluginSettings.nugetApiKey
$packageFile = $sharedSettings.PackageFile
Assert-Command dotnet
if (-not $packageFile) {
throw "NuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running NuGet."
}
if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) {
throw "NuGet plugin requires 'nugetApiKey' in scriptsettings.json."
}
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
if ([string]::IsNullOrWhiteSpace($nugetApiKey)) {
throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun."
}
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {
"https://api.nuget.org/v3/index.json"
}
else {
$pluginSettings.source
}
Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate
if ($LASTEXITCODE -ne 0) {
throw "Failed to push the package to NuGet."
}
Write-Log -Level "OK" -Message " NuGet push completed."
$sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,119 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Quality gate plugin for validating release readiness.
.DESCRIPTION
This plugin evaluates quality constraints using shared test
results and project files. It enforces coverage thresholds
and checks for vulnerable packages before release plugins run.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Test-VulnerablePackagesInternal {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
$findings = @()
foreach ($projectPath in $ProjectFiles) {
Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))"
$output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1
if ($LASTEXITCODE -ne 0) {
throw "dotnet list package --vulnerable failed for $projectPath."
}
$outputText = ($output | Out-String)
if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") {
$findings += [pscustomobject]@{
Project = $projectPath
Output = $outputText.Trim()
}
}
}
return $findings
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$coverageThresholdSetting = $pluginSettings.coverageThreshold
$failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities
$projectFiles = $sharedSettings.ProjectFiles
$testResult = $null
if ($sharedSettings.PSObject.Properties['TestResult']) {
$testResult = $sharedSettings.TestResult
}
if ($null -eq $testResult) {
throw "QualityGate plugin requires test results. Run the DotNetTest plugin first."
}
$coverageThreshold = 0
if ($null -ne $coverageThresholdSetting) {
$coverageThreshold = [double]$coverageThresholdSetting
}
if ($coverageThreshold -gt 0) {
Write-Log -Level "STEP" -Message "Checking coverage threshold..."
if ([double]$testResult.LineRate -lt $coverageThreshold) {
throw "Line coverage $($testResult.LineRate)% is below the configured threshold of $coverageThreshold%."
}
Write-Log -Level "OK" -Message " Coverage threshold met: $($testResult.LineRate)% >= $coverageThreshold%"
}
else {
Write-Log -Level "WARN" -Message "Skipping coverage threshold check (disabled)."
}
Assert-Command dotnet
$failOnVulnerabilities = $true
if ($null -ne $failOnVulnerabilitiesSetting) {
$failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting
}
$vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles
if ($vulnerabilities.Count -eq 0) {
Write-Log -Level "OK" -Message " No vulnerable packages detected."
return
}
foreach ($finding in $vulnerabilities) {
Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))"
$finding.Output -split "`r?`n" | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace($_)) {
Write-Log -Level "WARN" -Message " $_"
}
}
}
if ($failOnVulnerabilities) {
throw "Vulnerable packages were detected and failOnVulnerabilities is enabled."
}
Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,110 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
if (-not (Get-Command Get-PluginPathListSetting -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force
}
}
function Get-DotNetProjectPropertyValue {
param(
[Parameter(Mandatory = $true)]
[xml]$Csproj,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
# SDK-style .csproj files can have multiple PropertyGroup nodes.
# Use the first group that defines the requested property.
$propNode = $Csproj.Project.PropertyGroup |
Where-Object { $_.$PropertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
}
return $null
}
function Get-DotNetProjectVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
Write-Log -Level "INFO" -Message "Reading version(s) from .NET project files..."
$projectVersions = @{}
foreach ($projectPath in $ProjectFiles) {
if (-not (Test-Path $projectPath -PathType Leaf)) {
Write-Error "Project file not found at: $projectPath"
exit 1
}
if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") {
Write-Error "Configured project file is not a .csproj file: $projectPath"
exit 1
}
[xml]$csproj = Get-Content $projectPath
$version = Get-DotNetProjectPropertyValue -Csproj $csproj -PropertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projectPath"
exit 1
}
$projectVersions[$projectPath] = $version
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version"
}
return $projectVersions
}
function New-DotNetReleaseContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
# The array wrapper is intentional: without it, one configured project can collapse to a string,
# and later indexing [0] would return only the first character of the path.
$projectFiles = @(Get-PluginPathListSetting -Plugins $Plugins -PropertyName "projectFiles" -BasePath $ScriptDir)
$artifactsDirectory = Get-PluginPathSetting -Plugins $Plugins -PropertyName "artifactsDir" -BasePath $ScriptDir
if ($projectFiles.Count -eq 0) {
Write-Error "No .NET project files configured in plugin settings. Add 'projectFiles' to a relevant plugin."
exit 1
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
Write-Error "No artifacts directory configured in plugin settings. Add 'artifactsDir' to a relevant plugin."
exit 1
}
$projectVersions = Get-DotNetProjectVersions -ProjectFiles $projectFiles
# The first configured project is treated as the canonical version source for the release.
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
ProjectFiles = $projectFiles
ArtifactsDirectory = $artifactsDirectory
Version = $version
}
}
Export-ModuleMember -Function Get-DotNetProjectPropertyValue, Get-DotNetProjectVersions, New-DotNetReleaseContext

View File

@ -1,165 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
$gitToolsModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "GitTools.psm1"
if (Test-Path $gitToolsModulePath -PathType Leaf) {
Import-Module $gitToolsModulePath -Force
}
}
if (-not (Get-Command Get-PluginStage -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force
}
}
if (-not (Get-Command New-DotNetReleaseContext -ErrorAction SilentlyContinue)) {
$dotNetProjectSupportModulePath = Join-Path $PSScriptRoot "DotNetProjectSupport.psm1"
if (Test-Path $dotNetProjectSupportModulePath -PathType Leaf) {
Import-Module $dotNetProjectSupportModulePath -Force
}
}
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
}
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
return
}
Write-Log -Level "OK" -Message " Working directory is clean."
}
function Initialize-ReleaseStageContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$RemainingPlugins,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$ArtifactsDirectory,
[Parameter(Mandatory = $true)]
[string]$Version
)
Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
$remoteTagExists = Test-RemoteTagExists -Tag $SharedSettings.Tag -Remote "origin"
if (-not $remoteTagExists) {
Write-Log -Level "WARN" -Message " Tag $($SharedSettings.Tag) not found on remote. Pushing..."
Push-TagToRemote -Tag $SharedSettings.Tag -Remote "origin"
}
else {
Write-Log -Level "OK" -Message " Tag exists on remote."
}
if (-not $SharedSettings.PSObject.Properties['ReleaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.ReleaseDir)) {
$SharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $ArtifactsDirectory -Force
}
}
function New-EngineContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir,
[Parameter(Mandatory = $true)]
[string]$UtilsDir
)
$dotNetContext = New-DotNetReleaseContext -Plugins $Plugins -ScriptDir $ScriptDir
$currentBranch = Get-CurrentBranch
$releaseBranches = @(
$Plugins |
Where-Object { Test-IsPublishPlugin -Plugin $_ } |
ForEach-Object { Get-PluginBranches -Plugin $_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
)
$isReleaseBranch = $releaseBranches -contains $currentBranch
$isNonReleaseBranch = -not $isReleaseBranch
Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch
$version = $dotNetContext.Version
if ($isReleaseBranch) {
$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 the project version ($version)."
Write-Log -Level "WARN" -Message " Either update the tag or the project version."
exit 1
}
Write-Log -Level "OK" -Message " Tag found: $tag (matches project version)"
}
else {
$tag = "v$version"
Write-Log -Level "INFO" -Message " Using version from the package project (no tag required on non-release branches)."
}
return [pscustomobject]@{
ScriptDir = $ScriptDir
UtilsDir = $UtilsDir
CurrentBranch = $currentBranch
Version = $version
Tag = $tag
ProjectFiles = $dotNetContext.ProjectFiles
ArtifactsDirectory = $dotNetContext.ArtifactsDirectory
IsReleaseBranch = $isReleaseBranch
IsNonReleaseBranch = $isNonReleaseBranch
ReleaseBranches = $releaseBranches
NonReleaseBranches = @()
PublishCompleted = $false
}
}
function Get-PreferredReleaseBranch {
param(
[Parameter(Mandatory = $true)]
[psobject]$EngineContext
)
if ($EngineContext.ReleaseBranches.Count -gt 0) {
return $EngineContext.ReleaseBranches[0]
}
return "main"
}
Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch

View File

@ -1,368 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function Import-PluginDependency {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName,
[Parameter(Mandatory = $true)]
[string]$RequiredCommand
)
if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) {
return
}
$moduleRoot = Split-Path $PSScriptRoot -Parent
$modulePath = Join-Path $moduleRoot "$ModuleName.psm1"
if (Test-Path $modulePath -PathType Leaf) {
# Import into the global session so the calling plugin can see the exported commands.
# Importing only into this module's scope would make the dependency invisible to the plugin.
Import-Module $modulePath -Force -Global -ErrorAction Stop
}
if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) {
throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'."
}
}
function Get-ConfiguredPlugins {
param(
[Parameter(Mandatory = $true)]
[psobject]$Settings
)
if (-not $Settings.PSObject.Properties['Plugins'] -or $null -eq $Settings.Plugins) {
return @()
}
# JSON can deserialize a single plugin as one object or multiple plugins as an array.
# Always return an array so the engine can loop without special-case logic.
if ($Settings.Plugins -is [System.Collections.IEnumerable] -and -not ($Settings.Plugins -is [string])) {
return @($Settings.Plugins)
}
return @($Settings.Plugins)
}
function Get-PluginStage {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['Stage'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.Stage)) {
return "Release"
}
return [string]$Plugin.Stage
}
function Get-PluginBranches {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) {
return @()
}
# Strings are also IEnumerable in PowerShell, so exclude them or we would split into characters.
if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) {
return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) {
return @()
}
return @([string]$Plugin.branches)
}
function Test-IsPublishPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.Name)) {
return $false
}
return @('GitHub', 'NuGet') -contains ([string]$Plugin.Name)
}
function Get-PluginSettingValue {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
continue
}
if (-not $plugin.PSObject.Properties[$PropertyName]) {
continue
}
$value = $plugin.$PropertyName
if ($null -eq $value) {
continue
}
if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
continue
}
return $value
}
return $null
}
function Get-PluginPathListSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$rawPaths = @()
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value) {
return @()
}
# Same rule as above: treat a string as one path, not a char-by-char sequence.
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
$rawPaths += $value
}
else {
$rawPaths += $value
}
$resolvedPaths = @()
foreach ($path in $rawPaths) {
if ([string]::IsNullOrWhiteSpace([string]$path)) {
continue
}
$resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path)))
}
# Wrap again to stop PowerShell from unrolling a single-item array into a bare string.
return @($resolvedPaths)
}
function Get-PluginPathSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
return $null
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value)))
}
function Get-ArchiveNamePattern {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
continue
}
if (-not $plugin.Enabled) {
continue
}
$allowedBranches = Get-PluginBranches -Plugin $plugin
if ($allowedBranches.Count -gt 0 -and -not ($allowedBranches -contains $CurrentBranch)) {
continue
}
if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) {
return [string]$plugin.zipNamePattern
}
}
return "release-{version}.zip"
}
function Resolve-PluginModulePath {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory
)
$pluginFileName = "{0}.psm1" -f $Plugin.Name
$candidatePaths = @(
(Join-Path $PluginsDirectory $pluginFileName),
(Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName)
)
foreach ($candidatePath in $candidatePaths) {
if (Test-Path $candidatePath -PathType Leaf) {
return $candidatePath
}
}
return $candidatePaths[0]
}
function Test-PluginRunnable {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.Name)) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin entry with no Name."
}
return $false
}
if (-not $Plugin.Enabled) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' (disabled)."
}
return $false
}
if (Test-IsPublishPlugin -Plugin $Plugin) {
$allowedBranches = Get-PluginBranches -Plugin $Plugin
if ($allowedBranches.Count -eq 0) {
if ($WriteLogs) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' because no publish branches are configured."
}
return $false
}
if (-not ($allowedBranches -contains $SharedSettings.CurrentBranch)) {
if ($WriteLogs) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' on branch '$($SharedSettings.CurrentBranch)'."
}
return $false
}
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
if ($WriteLogs) {
Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
}
return $false
}
return $true
}
function New-PluginInvocationSettings {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings
)
$properties = @{}
foreach ($property in $Plugin.PSObject.Properties) {
$properties[$property.Name] = $property.Value
}
# Plugins receive their own config plus a shared Context object that carries runtime artifacts.
$properties['Context'] = $SharedSettings
return [pscustomobject]$properties
}
function Invoke-ConfiguredPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[Parameter(Mandatory = $false)]
[bool]$ContinueOnError = $true
)
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -PluginsDirectory $PluginsDirectory -WriteLogs:$true)) {
return
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.Name)'..."
try {
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
# Resolve Invoke-Plugin from the imported module explicitly so we call the plugin we just loaded,
# not some command with the same name from another module already in session.
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
& $invokeCommand -Settings $pluginSettings
Write-Log -Level "OK" -Message " Plugin '$($Plugin.Name)' completed."
}
catch {
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.Name)' failed: $($_.Exception.Message)"
if (-not $ContinueOnError) {
exit 1
}
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStage, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
pause

View File

@ -1,183 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Plugin-driven release engine.
.DESCRIPTION
This script is the orchestration layer for release automation.
It loads scriptsettings.json, evaluates the configured plugins in order,
builds shared execution context, and invokes each plugin's Invoke-Plugin
entrypoint with that plugin's own settings object plus runtime context.
The engine is intentionally generic:
- It does not embed release-provider-specific logic
- It preserves plugin execution order from scriptsettings.json
- It isolates plugin failures according to the stage/runtime policy
- It keeps shared orchestration helpers in dedicated support modules
.REQUIREMENTS
Tools (Required):
- Shared support modules required by the engine
- Any commands required by configured plugins or support helpers
.WORKFLOW
1. Load and normalize plugin configuration
2. Determine branch mode from configured plugin metadata
3. Validate repository state and resolve the release version
4. Build shared execution context
5. Execute plugins one by one in configured order
6. Initialize release-stage shared artifacts only when needed
7. Report completion summary
.USAGE
Configure plugin order and plugin settings in scriptsettings.json, then run:
pwsh -File .\Release-Package.ps1
.CONFIGURATION
All settings are stored in scriptsettings.json:
- Plugins: Ordered plugin definitions and plugin-specific settings
.NOTES
Plugin-specific behavior belongs in the plugin modules, not in this engine.
#>
# No parameters - behavior is controlled by configured plugin metadata:
# - non-release branches -> Run only the plugins allowed for those branches
# - release branches -> Require a matching tag and allow release-stage plugins
# Get the directory of the current script (for loading settings and relative paths)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
#region Import Modules
$utilsDir = Split-Path $scriptDir -Parent
# 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 PluginSupport module
$pluginSupportModulePath = Join-Path $scriptDir "PluginSupport.psm1"
if (-not (Test-Path $pluginSupportModulePath)) {
Write-Error "PluginSupport module not found at: $pluginSupportModulePath"
exit 1
}
Import-Module $pluginSupportModulePath -Force
# Import DotNetProjectSupport module
$dotNetProjectSupportModulePath = Join-Path $scriptDir "DotNetProjectSupport.psm1"
if (-not (Test-Path $dotNetProjectSupportModulePath)) {
Write-Error "DotNetProjectSupport module not found at: $dotNetProjectSupportModulePath"
exit 1
}
Import-Module $dotNetProjectSupportModulePath -Force
# Import EngineSupport module
$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1"
if (-not (Test-Path $engineSupportModulePath)) {
Write-Error "EngineSupport module not found at: $engineSupportModulePath"
exit 1
}
Import-Module $engineSupportModulePath -Force
#endregion
#region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
#endregion
#region Configuration
$pluginsDir = Join-Path $scriptDir "CorePlugins"
#endregion
#endregion
#region Main
Write-Log -Level "STEP" -Message "=================================================="
Write-Log -Level "STEP" -Message "RELEASE ENGINE"
Write-Log -Level "STEP" -Message "=================================================="
#region Preflight
$plugins = $configuredPlugins
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir
Write-Log -Level "OK" -Message "All pre-flight checks passed!"
$sharedPluginSettings = $engineContext
#endregion
#region Plugin Execution
$releaseStageInitialized = $false
if ($plugins.Count -eq 0) {
Write-Log -Level "WARN" -Message "No plugins configured in scriptsettings.json."
}
else {
for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
$plugin = $plugins[$pluginIndex]
$pluginStage = Get-PluginStage -Plugin $plugin
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -WriteLogs:$false) {
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.ArtifactsDirectory -Version $engineContext.Version
$releaseStageInitialized = $true
}
}
$continueOnError = $pluginStage -eq "Release"
Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -ContinueOnError:$continueOnError
}
}
if (-not $releaseStageInitialized) {
$noReleasePluginsLogLevel = if ($engineContext.IsNonReleaseBranch) { "INFO" } else { "WARN" }
Write-Log -Level $noReleasePluginsLogLevel -Message "No release plugins executed for branch '$($engineContext.CurrentBranch)'."
}
#endregion
#region Summary
Write-Log -Level "OK" -Message "=================================================="
if ($engineContext.IsNonReleaseBranch) {
Write-Log -Level "OK" -Message "NON-RELEASE RUN COMPLETE"
}
else {
Write-Log -Level "OK" -Message "RELEASE COMPLETE"
}
Write-Log -Level "OK" -Message "=================================================="
Write-Log -Level "INFO" -Message "Artifacts location: $($engineContext.ArtifactsDirectory)"
if ($engineContext.IsNonReleaseBranch) {
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
Write-Log -Level "INFO" -Message "To execute release-stage plugins, rerun from an allowed release branch such as '$preferredReleaseBranch'."
}
#endregion
#endregion

View File

@ -1,92 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
"description": "Configuration file for Release-Package.ps1 script.",
"Plugins": [
{
"Name": "DotNetTest",
"Stage": "Test",
"Enabled": true,
"project": "..\\..\\src\\MaksIT.Results.Tests",
"resultsDir": "..\\..\\testResults"
},
{
"Name": "QualityGate",
"Stage": "QualityGate",
"Enabled": true,
"coverageThreshold": 0,
"failOnVulnerabilities": true
},
{
"Name": "DotNetPack",
"Stage": "Build",
"Enabled": true,
"projectFiles": [
"..\\..\\src\\MaksIT.Results\\MaksIT.Results.csproj"
],
"artifactsDir": "..\\..\\release"
},
{
"Name": "CreateArchive",
"Stage": "Build",
"Enabled": true,
"zipNamePattern": "maksit.results-{version}.zip"
},
{
"Name": "GitHub",
"Stage": "Release",
"Enabled": true,
"branches": [
"main"
],
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-results",
"releaseNotesFile": "..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
},
{
"Name": "NuGet",
"Stage": "Release",
"Enabled": true,
"branches": [
"main"
],
"nugetApiKey": "NUGET_MAKS_IT",
"source": "https://api.nuget.org/v3/index.json"
},
{
"Name": "CleanupArtifacts",
"Stage": "Release",
"Enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
]
}
],
"_comments": {
"Plugins": {
"Name": "Plugin module file name in CorePlugins (for example, DotNetPack -> CorePlugins/DotNetPack.psm1).",
"Stage": "Execution phase. Supported values are Test, QualityGate, Build, and Release.",
"Enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"branches": "Used only by publish plugins such as GitHub and NuGet. When the current branch is not listed, publishing is skipped.",
"project": "DotNetTest plugin only. Path to the test project directory, relative to the script folder.",
"resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.",
"projectFiles": "DotNetPack, DotNetPublish, or another producer plugin can define the project files used for version discovery and artifact creation.",
"artifactsDir": "DotNetPack, DotNetPublish, or another producer plugin can define the artifacts output directory, relative to the script folder.",
"coverageThreshold": "QualityGate plugin only. Coverage threshold percent (0 disables threshold check).",
"failOnVulnerabilities": "QualityGate plugin only. If true, fail when vulnerable packages are detected.",
"githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.",
"repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.",
"releaseNotesFile": "GitHub plugin (or another notes consumer plugin) can define the release notes source file, relative to the script folder.",
"releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.",
"zipNamePattern": "CreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"nugetApiKey": "NuGet plugin only. Environment variable name containing the NuGet API key.",
"source": "NuGet plugin only. Feed URL passed to dotnet nuget push.",
"includePatterns": "CleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).",
"excludePatterns": "CleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip'])."
}
}
}

View File

@ -1,6 +1,3 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-ScriptSettings { function Get-ScriptSettings {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]

View File

@ -1,6 +1,3 @@
#requires -Version 7.0
#requires -PSEdition Core
<# <#
.SYNOPSIS .SYNOPSIS
PowerShell module for running tests with code coverage. PowerShell module for running tests with code coverage.
@ -11,7 +8,7 @@
.NOTES .NOTES
Author: MaksIT Author: MaksIT
Usage: pwsh -Command "Import-Module .\TestRunner.psm1" Usage: Import-Module .\TestRunner.psm1
#> #>
function Import-LoggingModuleInternal { function Import-LoggingModuleInternal {

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
pause

View File

@ -1,355 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Refreshes a local maksit-repoutils copy from GitHub.
.DESCRIPTION
This script clones the configured repository into a temporary directory,
refreshes the parent directory of this script, preserves existing
scriptsettings.json files in subfolders, and copies the cloned source
contents into that parent directory.
All configuration is stored in scriptsettings.json.
.EXAMPLE
pwsh -File .\Update-RepoUtils.ps1
.NOTES
CONFIGURATION (scriptsettings.json):
- dryRun: If true, logs the planned update without modifying files
- repository.url: Git repository to clone
- repository.sourceSubdirectory: Folder copied into the target directory
- repository.preserveFileName: Existing file name to preserve in subfolders
- repository.cloneDepth: Depth used for git clone
- repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh
#>
[CmdletBinding()]
param(
[switch]$ContinueAfterSelfUpdate,
[string]$TargetDirectoryOverride,
[string]$ClonedSourceDirectoryOverride,
[string]$TemporaryRootOverride
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# 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
# Refresh the parent directory that contains the shared modules and sibling tools.
$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) {
Split-Path $scriptDir -Parent
}
else {
[System.IO.Path]::GetFullPath($TargetDirectoryOverride)
}
$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path)
$selfUpdateDirectory = 'Update-RepoUtils'
function ConvertTo-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$normalizedPath = $Path.Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar)
return $normalizedPath.TrimStart('.', [System.IO.Path]::DirectorySeparatorChar).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
}
function Test-IsInRelativeDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$RelativePath,
[Parameter(Mandatory = $true)]
[string[]]$Directories
)
$normalizedRelativePath = ConvertTo-NormalizedRelativePath -Path $RelativePath
foreach ($directory in $Directories) {
$normalizedDirectory = ConvertTo-NormalizedRelativePath -Path $directory
if ([string]::IsNullOrWhiteSpace($normalizedDirectory)) {
continue
}
if (
$normalizedRelativePath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or
$normalizedRelativePath.StartsWith($normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
) {
return $true
}
}
return $false
}
#region Import Modules
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
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
#endregion
#region Load Settings
$settings = Get-ScriptSettings -ScriptDir $scriptDir
#endregion
#region Configuration
$repositoryUrl = $settings.repository.url
$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false }
$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' }
$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptsettings.json' }
$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 }
[string[]]$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) {
@(
$settings.repository.skippedRelativeDirectories |
ForEach-Object {
ConvertTo-NormalizedRelativePath -Path ([string]$_)
}
)
}
else {
@([System.IO.Path]::Combine('Release-Package', 'CustomPlugins'))
}
#endregion
#region Validate CLI Dependencies
Assert-Command git
Assert-Command pwsh
if ([string]::IsNullOrWhiteSpace($repositoryUrl)) {
Write-Error "repository.url is required in scriptsettings.json."
exit 1
}
#endregion
#region Main
Write-Log -Level "INFO" -Message "========================================"
Write-Log -Level "INFO" -Message "Update RepoUtils Script"
Write-Log -Level "INFO" -Message "========================================"
Write-Log -Level "INFO" -Message "Target directory: $targetDirectory"
Write-Log -Level "INFO" -Message "Dry run: $dryRun"
$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride)
$temporaryRoot = if ($ownsTemporaryRoot) {
Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N'))
}
else {
[System.IO.Path]::GetFullPath($TemporaryRootOverride)
}
try {
$clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) {
Write-LogStep "Cloning latest repository snapshot..."
& git clone --depth $cloneDepth $repositoryUrl $temporaryRoot
if ($LASTEXITCODE -ne 0) {
throw "git clone failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message "Repository cloned"
Join-Path $temporaryRoot $sourceSubdirectory
}
else {
[System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride)
}
if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) {
throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory"
}
if (-not $ContinueAfterSelfUpdate) {
if ($dryRun) {
Write-LogStep "Dry run self-update summary"
Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater"
}
else {
Write-LogStep "Refreshing updater files..."
$selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
$isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar)
$isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
$_.Name -ne $preserveFileName -and
($isRootFile -or $isUpdaterFile)
}
foreach ($sourceFile in $selfUpdateFiles) {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
$destinationPath = Join-Path $targetDirectory $relativePath
$destinationDirectory = Split-Path -Parent $destinationPath
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
}
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
}
Write-Log -Level "OK" -Message "Updater files refreshed"
}
if ($dryRun) {
Write-LogStep "Dry run bootstrap completed"
Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed"
}
else {
Write-LogStep "Relaunching the updated updater..."
& pwsh -File $currentScriptPath `
-ContinueAfterSelfUpdate `
-TargetDirectoryOverride $targetDirectory `
-ClonedSourceDirectoryOverride $clonedSourceDirectory `
-TemporaryRootOverride $temporaryRoot
if ($LASTEXITCODE -ne 0) {
throw "Relaunched updater failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message "Bootstrap phase completed"
return
}
}
$preservedFiles = @()
[string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory
$existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue
if ($existingPreservedFiles) {
foreach ($file in $existingPreservedFiles) {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName)
$backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_'))
$preservedFiles += [pscustomobject]@{
RelativePath = $relativePath
BackupPath = $backupPath
}
if (-not $dryRun) {
Copy-Item -Path $file.FullName -Destination $backupPath -Force
}
}
Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)"
}
else {
Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders"
}
if ($dryRun) {
Write-LogStep "Dry run summary"
Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files"
Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')"
Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory"
if ($preservedFiles.Count -gt 0) {
$preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", "
Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList"
}
Write-Log -Level "OK" -Message "Dry run completed. No files were modified."
return
}
Write-LogStep "Cleaning target directory..."
$filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
$_.Name -ne $preserveFileName -and
-not $isInSkippedDirectory
}
foreach ($file in $filesToRemove) {
Remove-Item -Path $file.FullName -Force
}
$directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory |
Sort-Object { $_.FullName.Length } -Descending
foreach ($directory in $directoriesToRemove) {
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName)
if (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) {
continue
}
$remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue
if (-not $remainingItems) {
Remove-Item -Path $directory.FullName -Force
}
}
Write-Log -Level "OK" -Message "Target directory cleaned"
Write-LogStep "Copying refreshed source files..."
$sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
Where-Object {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
-not $isInSkippedDirectory
}
foreach ($sourceFile in $sourceFilesToCopy) {
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
$destinationPath = Join-Path $targetDirectory $relativePath
$destinationDirectory = Split-Path -Parent $destinationPath
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
}
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
}
foreach ($skippedDirectory in $updatePhaseSkippedDirectories) {
$skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory
if (Test-Path -Path $skippedSourcePath) {
Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory"
}
}
Write-Log -Level "OK" -Message "Source files copied"
if ($preservedFiles.Count -gt 0) {
foreach ($preservedFile in $preservedFiles) {
if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) {
continue
}
$restorePath = Join-Path $targetDirectory $preservedFile.RelativePath
$restoreDirectory = Split-Path -Parent $restorePath
if (-not (Test-Path -Path $restoreDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null
}
Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force
}
Write-Log -Level "OK" -Message "$preserveFileName files restored"
}
Write-Log -Level "OK" -Message "========================================"
Write-Log -Level "OK" -Message "Update completed successfully!"
Write-Log -Level "OK" -Message "========================================"
}
finally {
if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) {
Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
#endregion

View File

@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Update RepoUtils Script Settings",
"description": "Configuration for the Update-RepoUtils utility.",
"dryRun": false,
"repository": {
"url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git",
"sourceSubdirectory": "src",
"preserveFileName": "scriptsettings.json",
"cloneDepth": 1,
"skippedRelativeDirectories": [
"Release-Package/CustomPlugins"
]
}
}